-
Notifications
You must be signed in to change notification settings - Fork 434
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
LinkPrefetchObserver
: listen for complementary events
#1147
Conversation
2efc325
to
57d6d21
Compare
@@ -77,12 +86,18 @@ export class LinkPrefetchObserver { | |||
) | |||
|
|||
prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl) | |||
|
|||
link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: 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.
@afcapel this line is the crux of this PR. Adding listeners directly to the <a>
without removing them poses a risk of leaking memory from the instances. Additionally, there isn't a parallel pattern for touchstart
(and touchend
) events, so I'm not sure that cleanup is currently occurring on touch devices.
The rest of the changes in this diff support that setup-and-teardown.
Once this is merged, adding support for focusin and focusout events would be fairly trivial.
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.
@afcapel if this is going to be enabled by default, I think this change is important to consider for touch devices.
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.
Adding listeners directly to the < a > without removing them poses a risk of leaking memory from the instances.
When once
is true
the listener would be automatically removed when invoked. So there wouldn't be a risk of memory leaks. Or am I missing something?
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.
Ahh just read the description, got it. Sorry, was linked directly to this comment from #1167 😃
@@ -9,10 +9,14 @@ import { StreamMessage } from "../core/streams/stream_message" | |||
import { FetchMethod, FetchRequest } from "../http/fetch_request" | |||
import { prefetchCache, cacheTtl } from "../core/drive/prefetch_cache" | |||
|
|||
const observedEvents = { | |||
"mouseenter": "mouseleave", | |||
"touchstart": "touchcancel" |
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.
@seanpdoyle I don't think touchcancel
is mimicking mouseleave
here. touchcancel
means that a touch point has been interrupted in an unexpected manner, but a touch point could leave the target in another ways.
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 think the spirit of the teardown-side of event listening is that unless the click is navigated to 100ms
after the setup-side, the prefetching should be reset.
Would touchend be more appropriate? Would mapping observedEvents
to an array of listeners be better?
const observedEvents = {
"mouseenter": ["mouseleave"],
"touchstart": ["touchcancel", "touchend"],
"focusin": ["focusout"]
}
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.
@seanpdoyle what do you think about 5d15bb3 ? It's basically the same idea, but we check if the prefetch request is still relevant on touchmove
and touchend
events, instead.
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.
@afcapel I like that approach. I wonder if there's a way to make the event listener callback generic enough to apply to all types.
Maybe something like:
#checkIfPrefetchValid(event) {
const targets = "targetTouches" in event ?
[event.target, ...event.targetTouches] :
[event.target]
const interactingWithPrefetchedLink = targets.some((target) => target === this.#prefetchedLink)
if (interactingWithPrefetchedLink) {
// .. do the cancellation
}
}
That change could support the idea of observedEvents
mapping from "start" to "end" events in a generic enough way to support expanding support for incorporate focus change for keyboard navigators.
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 works, although I'm not sure yet how preload on focus events would work so it's hard to tell if it will generalize properly. We can add the abstraction now or when/if we add support for prefetching on focus events.
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 can add the abstraction now
Regardless of focus support, I think an abstraction that correlates which events start a prefetch with the events that cancel a prefetch has value.
I'm not sure yet how preload on focus events would work
In what way is a focusin
event that is not followed by a focusout
event 100ms later different than a mouseenter
that is not followed by a mouseleave
100ms later? What other facets are there to consider focus keyboard navigation?
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.
Regardless of focus support, I think an abstraction that correlates which events start a prefetch with the events that cancel a prefetch has value.
Fair enough, If you can update the PR to handle touchend
and touchmove
I'll merge it 👍 A test exercising the prefetch cancellation would also be useful.
Regarding prefetching on focusin
/focusout
, as @brunoprietog pointed the keyboard navigation may need a special timing. But honestly, I'm not sure, we'd have to test it on realistic conditions to tell.
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.
A test exercising the prefetch cancellation would also be useful
Unfortunately, I can't find a way to start then end a touch
event in Playwright. The closest that I came to finding a solution was the Locator.tap method, but that ends up definitively tapping the link as a navigation, even when immediately followed by tapping elsewhere in the screen.
57d6d21
to
7d338b6
Compare
7d338b6
to
06a6bfd
Compare
const targets = "targetTouches" in event ? | ||
[event.target, ...event.targetTouches] : | ||
[event.target] | ||
const leavingPrefetchedLink = targets.some((target) => target === this.#prefetchedLink) |
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.
Is this correct for touch events? If the touchend
event still has a targetTouch
that is the prefetched link, that means we didn't leave the link, right? Same with touchmove
?
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.
Maybe TouchEvent.changedTouches is a better candidate:
- For the touchstart event, it is a list of the touch points that became active with the current event.
- For the touchmove event, it is a list of the touch points that have changed since the last event.
- For the touchend event, it is a list of the touch points that have been removed from the surface (that is, the set of touch points corresponding to fingers no longer touching the surface).
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 if that will work. For example, if a touchend
fires and changedTouches
points to another element, that doesn't mean the link is not still being tapped. It could be another finger that was removed from the surface. Same with touchmove
.
Maybe I'm missing something, but I think we'll have to use two different handlers. For example #checkIfPrefetchValidAfterMouseLeave
and #checkIfPrefetchValidAfterTouchChange
. The two cases have opposite logic: for mouseleave
we left the link if the event target is the link; for touch changes we left the link if none of the targets is the link.
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.
After more reflection, I realize that I've misread the conditionals in 5d15bb3.
I've pushed up a new commit use Array.some
to check a touchend
event's changedTouches
for the presence of the link, Array.some
to check for a touchmove
event's targetTouches
for the absence of the link, and all other types of events for the target itself.
Even though its a singular conditional and a singular event listener callback, it isn't as general purpose as I originally intended.
With that being acknowledged, I think that if a change accounts for touch events, removes all event listeners that it attaches, and doesn't leak additional listeners, it's worth shipping. I don't have a preference between this PR and 5d15bb3. If it's easier for you to coordinate the necessary steps to merge your fix, I say go for it.
06a6bfd
to
a90c55b
Compare
Prior to this commit, the `LinkPrefetchObserver` only listened for `mouseleave` events to clear the `PrefetchCache` instance. Not only were `mouseenter` events excluded, but the `mouseleave` event listeners were attached directly to the `<a>` element with a `{ once: true }` option. While unlikely, its was for those event listeners to never be removed if a `mouseleave` were to not fire. Similarly, during `touchstart` events the event listener were added, but never removed since there wasn't a complementary `touchend` or `touchcancel` event firing to remove it. This commit makes two changes to the event listeners: 1. extract the `addEventListener` calls to a loop, looping over `mouseenter` and `touchstart` event names 2. define complementary events for both `mouseenter` (`mouseleave`) and `touchstart` (`touchend` and `touchcancel`) By moving the cancellation logic out of individual event listeners and into an `this.eventTarget`-wide scope, we limit the risk of leaking listeners. Similarly, we only ever instantiate one per event-pairing. To track the `<a>` element reference, define both a `this.#tryToCancelPrefetchRequest` method and a `this.#linkToPrefetch` property to hold the reference to the `<a>` element in question.
a90c55b
to
9cd58e4
Compare
Closing in favor of #1167. |
Prior to this commit, the
LinkPrefetchObserver
only listened formouseleave
events to clear thePrefetchCache
instance. Not only weremouseenter
events excluded, but themouseleave
event listeners were attached directly to the<a>
element with a{ once: true }
option.While unlikely, its was for those event listeners to never be removed if a
mouseleave
were to not fire. Similarly, duringtouchstart
events the event listener were added, but never removed since there wasn't a a complementarymouseleave
event firing to remove it.This commit makes two changes to the event listeners:
addEventListener
calls to a loop, looping overmouseenter
andtouchstart
event namesmouseenter
andtouchstart
By moving the cancellation logic out of individual event listeners and into an
this.eventTarget
-wide scope, we limit the risk of leaking listeners. Similarly, we only ever instantiate one per event-pairing.To track the
<a>
element reference, define both athis.#tryToCancelPrefetchRequest
method and athis.#linkToPrefetch
property to hold the reference to the<a>
element in question.