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

LinkPrefetchObserver: listen for complementary events #1147

Closed

Conversation

seanpdoyle
Copy link
Contributor

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 a complementary mouseleave 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 and touchstart

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.

@seanpdoyle seanpdoyle force-pushed the prefetch-event-listeners branch 2 times, most recently from 2efc325 to 57d6d21 Compare February 2, 2024 14:52
@@ -77,12 +86,18 @@ export class LinkPrefetchObserver {
)

prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl)

link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: true })
Copy link
Contributor Author

@seanpdoyle seanpdoyle Feb 2, 2024

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.

Copy link
Contributor Author

@seanpdoyle seanpdoyle Feb 5, 2024

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.

Copy link
Contributor

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?

Copy link
Contributor

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"
Copy link
Collaborator

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.

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 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"]
}

Copy link
Collaborator

@afcapel afcapel Feb 5, 2024

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.

Copy link
Contributor Author

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.

Copy link
Collaborator

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.

Copy link
Contributor Author

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?

Copy link
Collaborator

@afcapel afcapel Feb 5, 2024

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.

Copy link
Contributor Author

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.

@seanpdoyle seanpdoyle force-pushed the prefetch-event-listeners branch from 57d6d21 to 7d338b6 Compare February 6, 2024 15:01
@seanpdoyle seanpdoyle requested a review from afcapel February 6, 2024 15:05
@seanpdoyle seanpdoyle force-pushed the prefetch-event-listeners branch from 7d338b6 to 06a6bfd Compare February 6, 2024 15:10
const targets = "targetTouches" in event ?
[event.target, ...event.targetTouches] :
[event.target]
const leavingPrefetchedLink = targets.some((target) => target === this.#prefetchedLink)
Copy link
Collaborator

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?

Copy link
Contributor Author

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

Copy link
Collaborator

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.

Copy link
Contributor Author

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.

@seanpdoyle seanpdoyle force-pushed the prefetch-event-listeners branch from 06a6bfd to a90c55b Compare February 6, 2024 15:31
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.
@seanpdoyle
Copy link
Contributor Author

Closing in favor of #1167.

@seanpdoyle seanpdoyle closed this Feb 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants