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 amp-sidebar local navigation and history #5520
Conversation
if (this.visibilityState_ != 'visible') { | ||
return; | ||
} | ||
console.log('popping: ', e.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.
log
@@ -103,6 +107,19 @@ export class AmpSidebar extends AMP.BaseElement { | |||
this.registerAction('toggle', this.toggle_.bind(this)); | |||
this.registerAction('open', this.open_.bind(this)); | |||
this.registerAction('close', this.close_.bind(this)); | |||
|
|||
installGlobalClickListenerForDoc(this.getAmpDoc()).addBeforeHandler(e => { |
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 we do something like this, I'd recommend we instead expose onNavigate(href, target, isLocal)
method or something so that we don't have to look for all the same A
and its parameters. Alternatively, I don't see an issue to just add a click listener directly on the sidebar
itself. Why do we need to listen for global clicks 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.
Why do we need to listen for global clicks anyway?
This is basically to make sure we run before we actually handle the click (which is handled inside document-click
handler), and the event is captured there and from testing capturing seems to be first-capture-first-serve and since document-click
listener is added on document load it's always going to be the first handle to be called.
In this case we want our handler inside sidebar to be called before it to allow us to pop before pushing.
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 don't believe document-click
is a capture. It'd seem to be illogical to have it as a capture click, since it's a classical bubbling processing.
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 checked you're right it's not capture. I could swear it was 😅 when I checked
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.
"Schrodinger's capture" (c)
@@ -37,7 +37,7 @@ const ORIGINAL_HREF_ATTRIBUTE = 'data-amp-orig-href'; | |||
* @param {!./service/ampdoc-impl.AmpDoc} ampdoc | |||
*/ | |||
export function installGlobalClickListenerForDoc(ampdoc) { | |||
fromClassForDoc(ampdoc, 'clickhandler', ClickHandler); | |||
return fromClassForDoc(ampdoc, 'clickhandler', ClickHandler); |
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 add @return
@@ -73,6 +73,9 @@ export class ClickHandler { | |||
this.boundHandle_ = this.handle_.bind(this); | |||
this.ampdoc.getRootNode().addEventListener('click', this.boundHandle_); | |||
} | |||
|
|||
/** @private {!Array<!function(!Event)>} */ | |||
this.beforeHandlers_ = []; |
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 have observables for this.
@@ -103,6 +107,19 @@ export class AmpSidebar extends AMP.BaseElement { | |||
this.registerAction('toggle', this.toggle_.bind(this)); | |||
this.registerAction('open', this.open_.bind(this)); | |||
this.registerAction('close', this.close_.bind(this)); | |||
|
|||
installGlobalClickListenerForDoc(this.getAmpDoc()).addBeforeHandler(e => { |
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.
Where is the listener removed? We don't need it working all the time, right? Only when the sidebar is open, I assume.
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.
Ah good point. Will update once other parts are figured out.
@@ -91,9 +94,20 @@ export class ClickHandler { | |||
* @param {!Event} e | |||
*/ | |||
handle_(e) { | |||
for (let i = 0; i < this.beforeHandlers_.length; i++) { |
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: observable
@@ -286,10 +300,12 @@ export function onDocumentElementClick_( | |||
} | |||
|
|||
if (tgtLoc.hash != curLoc.hash) { | |||
timerFor(win).delay(() => { |
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 is "wait" for?
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.
Explained in the above discussion.
@@ -160,7 +177,9 @@ export class AmpSidebar extends AMP.BaseElement { | |||
}, ANIMATION_TIMEOUT); | |||
}); | |||
}); | |||
this.getHistory_().push(this.close_.bind(this)).then(historyId => { | |||
this.getHistory_().push(() => { | |||
this.close_(); |
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 didn't understand the issue with event sequence. What's the exact event sequence in this case?
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.
So without the doing the delay
when pushing the new state in document-submit
the sequence goes like this:
- Open Sidebar
- Click Section 1 Link
- We Close Sidebar
- Which also Pops History at 1
- And then handle the actual click on the link and Push history to 1
- And then Popping actually happen and history is popped to 0 removing the new push with it.
I am not entirely sure yet why does this happen. But this might be dependent on when the viewer is updating their stack index and communicate the change back with the amp doc. In viewer.html
for example we do that inside the popstate
event handler. This is were my theory about the event queue delaying the stack index update from happening right away after the close-pop.
(I know the viewer.html
implementation is incomplete but my guess is the Google.com viewer is doing something similar, I've also tested with the sandbox viewer and seen the same experience)
So to summarize this is what is being logged in sequence when the timeout is not in place.
[VIEWER] push history to 1
[VIEWER] pop history at 1
[VIEWER] push history to 1
[VIEWER] history popped to 0 # Notice our history push is gone now!
After adding the timeout this gives time for popstate
handlers to update the stack indexes. These are the logs after timeout:
[VIEWER] push history to 1
[VIEWER] pop history at 1
[VIEWER] history popped to 0
[VIEWER] push history to 1 # Yay pushed state preserved!
Does this make any sense 😄 because I am not 80% share!
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.
So event processing loop can possibly interfere here. In this case, though, waiting for one event loop (e.g. setTimeout(..., 0)
) should be enough. Can you try that in your experiments. We just need this info to make a better decision.
Generally, I'd say, we don't have here enough information to make the right calls. We need more research and understand the exact sequence of events much better. If we need more data from sandbox - we should find a way to get it. One important thing: our history supports queuing and returns promises for push
and pop
. In theory, these promises should be sufficient to address any risk conditions. For this purpose I'm a little skeptical that we need timers.
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.
During myinitiall experiments I couldn't fix with a small timeout. I didn't investigate this enough bit wanted to see if you might have an idea why that might be happening.
I'll do some more work on this and see if I can use the promises for this.
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.
@dvoytenko I've done some more investigation, so basically this is my understanding of what's happening. The call to history.pop
and history.push
happens right after each other. The viewer then receives a message for each, causing it to execute first popState
and pushState
callbacks right after each other.
At this point the viewer hasn't actually popped the state nor did it update the stackIndex because that happens in statepopped
event handler onPopState_
, this is also where the viewer communicate back to the ampdoc the new stackIndex
with the historyPopped
message.
I've just sent out a new change to the PR where I basically wait for any popping to be confirmed before any pushing happens.
My understanding is that we might be already doing something very similar in the natural implementation of history but I haven't looked into that a lot.
This fixes the problem, basically it waits to make sure the stack index is reflected.
What do you think?
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 makes sense. To confirm, you'd wait for viewer to respond as with "push is 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.
More of the "pop is done" - I don't think the same problem happens with pushing since there's no pushstate
event.
cc/ @aghassemi and @camelburrito this is the PR we were discussing today. |
We found a Contributor License Agreement for you (the sender of this pull request) and all commit authors, but as best as we can tell these commits were authored by someone else. If that's the case, please add them to this pull request and have them confirm that they're okay with these commits being contributed to Google. If we're mistaken and you did author these commits, just reply here to confirm. |
baa9b4c
to
e2a6a27
Compare
CLAs look good, thanks! |
22fc2bf
to
22496a9
Compare
@dvoytenko I cleaned up the PR PTAL 👀 - if this looks good I'll add tests. I'll also test with the sandbox viewer. |
@mkhatib Is this ready to be reviewed again? |
@dvoytenko yes, I'll work on tests |
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 don't understand this PR at all. Your description's use case works as it's written, and that feels like the correct behavior to me.
|
||
this.element.addEventListener('click', e => { | ||
const target = closestByTag(dev().assertElement(e.target), 'A'); | ||
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.
Check that target
has a href
, too. Also, this code's duplicated.
screenReaderCloseButton.addEventListener('click', () => { | ||
this.close_(); | ||
}); | ||
screenReaderCloseButton.addEventListener('click', () => this.close_()); |
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 seems like we should create a boundClose
, since it's used so frequently.
// Current implementation doesn't wait for response from viewer. | ||
this.updateStackIndex_(this.stackIndex_ + 1); | ||
this.viewer_.postPushHistory(this.stackIndex_); | ||
return Promise.resolve(this.stackIndex_); |
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 return #stackIndex
.
constructor(viewer) { | ||
constructor(viewer, win) { | ||
/** @private @const {!../service/timer-impl.Timer} */ | ||
this.timer_ = timerFor(win); |
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.
You can just do viewer.ampdoc.win
. No need to pass win
as an arg here.
// between popping and pushing might happen causing history to be messed | ||
// up. This will only wait 100ms to avoid getting stuck if confirmation | ||
// never arrived. | ||
return Promise.race([this.timer_.promise(100), this.awaitPoppingPromise_]) |
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 awaitPoppingPromise_
necessary? Isn't queuing feature already provided by the parent History
class? It'd seem all we need to do is return a promise from viewer's postPushHistory
call, which may already be the case.
Closing this one for #5961 |
Debugging and experimenting with amp-sidebar bug #4184
Try the demo for this.