New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix local navigation history management by handling hash navigations #5961
Conversation
…in standalone mode
Ping me back after tests are in place! |
dev().fine(TAG_, 'popstate event: ' + this.win.history.length + ', ' + | ||
JSON.stringify(e.state)); | ||
eventPass.schedule(); | ||
}; | ||
this.hashchangeHandler_ = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dropping hashchange handler as it is not needed now that we handle our own hash-navigations when users click on anchors. And after investigating browsers, they all fire popstate
on navigating backward or forward in history.
Adding @dvoytenko to this since this make a bit of more changes to history-impl. Tested this in a standalone doc here and inside the viewer in this link. |
@@ -219,7 +219,8 @@ | |||
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript> | |||
<meta name="apple-itunes-app" content="app-id=828256236, app-argument=medium://p/cb7f223fad86"> | |||
<link rel="amp-manifest" href="medium-manifest.json"> | |||
<script async src="./viewer-integr.js" data-amp-report-test="viewer-integr.js"></script> | |||
<!--<script async src="./viewer-integr.js" data-amp-report-test="viewer-integr.js"></script>--> | |||
<script async src="https://cdn.ampproject.org/viewer/google/v5.js"></script> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's rollback. This file is used for viewer.html
regularly for testing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
|
||
this.element.addEventListener('click', e => { | ||
const target = dev().assertElement(e.target); | ||
if (!target) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This wouldn't happen with the assertElement
above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
if (!target) { | ||
return; | ||
} | ||
if (target.tagName == 'A' && target.href) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's common to nest elements into <a>
, so we need to do closest
test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
* | ||
* @param {string} target | ||
* @param {string} previousTarget | ||
* @returns {Promise.<T>} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No dots in types and what's T
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
@@ -109,6 +112,20 @@ export class History { | |||
} | |||
|
|||
/** | |||
* | |||
* @param {string} target | |||
* @param {string} previousTarget |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not super happy about previousTarget
. First, it's previousHash since it may not be a target. Second, seems like we should know it here anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was trying to avoid re-parsing URL but I am ok with getting it from win.location though in here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
this.popstateHandler_ = e => { | ||
if (this.ignoreUpcomingPopstate_) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I mentioned. I'm a bit concerned with sync/async behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tested this out. All browsers are firing the event handler synchronously.
this.whenReady_(() => { | ||
this.ignoreUpcomingPopstate_ = true; | ||
// TODO(mkhatib, #6095): Chrome iOS will add extra states for location.replace. | ||
this.win.location.replace(target); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
target
to me sort of assumes that there's no #
. In which case it should be added here. Generally, I think we'll be served well by supporting both cases where target is passed here with and w/o #
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
* @param {*=} state | ||
* @param {(string|undefined)=} title | ||
* @param {(string|undefined)=} url | ||
* @private | ||
*/ | ||
historyReplaceState_(state, title, url) { | ||
this.assertReady_(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also from earlier experiments, reverted this back.
/** @override */ | ||
cleanup_() { | ||
this.unlistenOnHistoryPopped_(); | ||
if (this.origReplaceState_) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about pushState
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry this is not needed and is risidual from earlier experimentation. Will drop. We do not override either pushState
or replaceState
in virtual history.
@dvoytenko let me know if this is looks good so I can update tests and add some more. |
const previousHash = this.ampdoc_.win.location.hash; | ||
return this.push(() => { | ||
this.ampdoc_.win.location.replace(previousHash || '#'); | ||
}).then(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why separate this into two blocks?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure where is the 2nd block, but if you mean inside the push that's the wrong place to have it, the push argument is a callback to be called when popping that state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a super confusing API. 👍
@@ -590,6 +636,12 @@ export class HistoryBindingVirtual_ { | |||
} | |||
|
|||
/** @override */ | |||
replaceStateForTarget(target) { | |||
let hash = target.indexOf('#') == 0 ? target : `#${target}`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just test target[0]
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not done?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We actually dropped this statement all together and now just assert that it starts with a #
. Github is probably showing you a previous change. This should be done.
@@ -590,6 +636,12 @@ export class HistoryBindingVirtual_ { | |||
} | |||
|
|||
/** @override */ | |||
replaceStateForTarget(target) { | |||
let hash = target.indexOf('#') == 0 ? target : `#${target}`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why would we ever get a non #
leading string?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just supporting either cases.
*/ | ||
replaceStateForTarget(target) { | ||
this.whenReady_(() => { | ||
let hash = target.indexOf('#') == 0 ? target : `#${target}`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
replaceStateForTarget(target) { | ||
const previousHash = this.ampdoc_.win.location.hash; | ||
return this.push(() => { | ||
this.ampdoc_.win.location.replace(previousHash || '#'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this already have happened?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the onpop
callback, when the user hit back we execute this. What do you mean shouldn't this already have happened?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought this was a #then
callback, not a pop.
// TODO(mkhatib, #6095): Chrome iOS will add extra states for location.replace. | ||
this.win.location.replace(hash); | ||
this.ignoreUpcomingPopstate_ = false; | ||
this.historyReplaceState_(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we call this first? If so, we could drop the ignoreUpcomingPopstate_
and instead check if the navigation is to the same URL as the previous. If it is, then we know there's not really a popstate
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what do you mean navigation to the same URL. We're going to a new URL here, where do we get access to the previous
Url?
The popstate
happens for any call for location.replace
in this case we don't want that to be considered a popstate
.
Mind elaborating? I've tried testing what you suggested but couldn't get it to work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#replaceState
should trigger a popstate
, too right? Well, call it first, that's your "real" popstate
. On every popstate
, record the current URL. Now, we need to call #replace
to get :target
support, but it'll trigger it's own popstate
(a duplicate). But because we know what URL we were at, we know this is a duplicate URL. Don't record this popstate
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No replaceState
does not trigger a popstate
. Ok if I understand correctly, we want to replace ignoreUpcomingPopstate_
with something like this.previouseUrl_
hash and then check that and ignore that popstate
event if the current hash is equal to the previous hash? This should be doable I believe.
What advantages does this have over the ignoreUpcomingPopstate_
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No replaceState does not trigger a popstate
Well that's dumb.
What advantages does this have over the ignoreUpcomingPopstate_?
This try-finally boolean is icky. 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. Let me know if this looks good!
@@ -427,6 +435,7 @@ export class FakeHistory { | |||
throw new Error('can\'t go forward'); | |||
} | |||
this.index = newIndex; | |||
this.win.eventListeners.fire({type: 'popstate'}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
popstate
should fire on all history navigation. @dvoytenko you also seem to allow an optional fireEvent
param for pushState
and replaceState
. These never actually fire so not sure what we try to test there, let me know if we should just drop them or keep them. Keeping them is not hurting but might give the impression that pushState
and replaceState
needs to fire.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well. It's a very optional argument. I just didn't want to explode number of private/public methods here. And replaceState
/pushState
are used internally for FakeLocation
.
@@ -331,7 +339,7 @@ class FakeLocation { | |||
*/ | |||
change_(args) { | |||
const change = parseUrl(this.url_.href); | |||
Object.assign(change, args); | |||
Object.assign({}, change, args); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This used to throw a can't set property because the change
is frozen. This is ok, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely!
@dvoytenko this is ready for another look - added tests and all 👀 PTAL |
replaceStateForTarget(target) { | ||
this.whenReady_(() => { | ||
const hash = target[0] == '#' ? target : `#${target}`; | ||
this.ignoreUpcomingPopstate_ = true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the events are synchronous, does it mean that replace
can fail? Please check. If so, we may need a try/finally
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like it fails if the location is invalid or different origin than executing script.
We should probably limit the replaceStateForTarget to be just for hash navigation. Which is we kinda do with the first like appending a #
but maybe we should just handle the explicit #
and throw if the target passed does not start with a #
.
What do you think?
I'll also add the try-finally
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SG. Should definitely be limited to the same everything - only hash.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
LGTM with one request. |
@dvoytenko mind taking one last look 👀 |
LGTM. Pending comments by others. |
@jridgewell Done. Let me know if this looks 👀 good |
@@ -374,6 +403,9 @@ export class HistoryBindingNatural_ { | |||
// On pop, stack is not allowed to go prior to the starting point. | |||
stackIndex = Math.max(stackIndex, this.startIndex_); | |||
return this.whenReady_(() => { | |||
// Popping history forget the last navigated hash since we can't really | |||
// know what hash the browser is going to go to. | |||
this.lastNavigatedHash_ = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this be whatever the hash is after #back
is called?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. But we don't really know what's that going to be (Browser history API doesn't expose that for privacy reasons). However, we set the hash in the popstate
event once we already been back in history.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
replaceStateForTarget(target) { | ||
dev().assert(target[0] == '#', 'target should start with a #'); | ||
this.whenReady_(() => { | ||
this.lastNavigatedHash_ = target; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
Yay! 💃 💃 💃 💃 💃 |
Previously we were only handling hash navigations in iframe'd docs, this change expands this to always install the
document.click
handler for standalone docs and let runtime handle hash navigation.This allows our natural history implementation to properly queue popping/pushing states.
This should solve both natural and virtual history.
Related to: #4184