Skip to content
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

Merged
merged 11 commits into from Nov 18, 2016

Conversation

mkhatib
Copy link
Contributor

@mkhatib mkhatib commented Nov 1, 2016

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

@camelburrito
Copy link
Contributor

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_ = () => {
Copy link
Contributor Author

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.

@mkhatib
Copy link
Contributor Author

mkhatib commented Nov 9, 2016

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>
Copy link
Contributor

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.

Copy link
Contributor Author

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) {
Copy link
Contributor

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.

Copy link
Contributor Author

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) {
Copy link
Contributor

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.

Copy link
Contributor Author

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>}
Copy link
Contributor

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?

Copy link
Contributor Author

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
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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_) {
Copy link
Contributor

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.

Copy link
Contributor Author

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);
Copy link
Contributor

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 #

Copy link
Contributor Author

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_();
Copy link
Contributor

Choose a reason for hiding this comment

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

Why?

Copy link
Contributor Author

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_) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What about pushState?

Copy link
Contributor Author

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.

@mkhatib
Copy link
Contributor Author

mkhatib commented Nov 10, 2016

@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(() => {
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

Just test target[0].

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Copy link
Contributor

Choose a reason for hiding this comment

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

Not done?

Copy link
Contributor Author

@mkhatib mkhatib Nov 17, 2016

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}`;
Copy link
Contributor

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?

Copy link
Contributor Author

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}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

Ditto.

Copy link
Contributor Author

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 || '#');
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

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_();
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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_?

Copy link
Contributor

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. 😄

Copy link
Contributor Author

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'});
Copy link
Contributor Author

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.

Copy link
Contributor

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);
Copy link
Contributor Author

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?

Copy link
Contributor

Choose a reason for hiding this comment

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

Definitely!

@mkhatib
Copy link
Contributor Author

mkhatib commented Nov 16, 2016

@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;
Copy link
Contributor

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

Copy link
Contributor Author

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

Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@dvoytenko
Copy link
Contributor

LGTM with one request.

@mkhatib
Copy link
Contributor Author

mkhatib commented Nov 17, 2016

@dvoytenko mind taking one last look 👀

@dvoytenko
Copy link
Contributor

LGTM. Pending comments by others.

@mkhatib
Copy link
Contributor Author

mkhatib commented Nov 18, 2016

@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;
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

Add a comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@mkhatib mkhatib merged commit ff7b32b into ampproject:master Nov 18, 2016
@mkhatib mkhatib deleted the doc-click-non-embed branch November 18, 2016 19:20
@mkhatib
Copy link
Contributor Author

mkhatib commented Nov 18, 2016

Yay! 💃 💃 💃 💃 💃

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

Successfully merging this pull request may close these issues.

None yet

4 participants