Skip to content

INP Loses Target Element After SPA Route Change #567

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

Closed
dmitiiv opened this issue Nov 11, 2024 · 15 comments
Closed

INP Loses Target Element After SPA Route Change #567

dmitiiv opened this issue Nov 11, 2024 · 15 comments

Comments

@dmitiiv
Copy link

dmitiiv commented Nov 11, 2024

How to reproduce

  • Create any default REACT SPA (Vite etc)
  • Start listerning onINP (web-vitals/attribution)
  • Change route and then toggle the tab

Not always, but systematically, there is a loss of the target element and selector. At the same time, firstEntryWithTarget?.target == undefined and interactionTargetMap contains an element by interactionId, but firstEntry.interactionId differs from the one saved in interactionTargetMap and is always greater than it by 7

@tunetheweb
Copy link
Member

Can you create an example to show the issue here?

Chrome does increment interactionids by 7 but FID should be the first interactionId for the page, so odd that it's later.

@dmitiiv
Copy link
Author

dmitiiv commented Nov 19, 2024

I described how to reproduce the problem above. To do this, you need to create a SPA application (I used Vite + React). I generate the INP metric via select with a shift of the block DOM element.

  1. I click on the select
  2. I do not perform actions that may lead to a change in the visiblitystate of the page
  3. In the basic application, I click on the About router change link
  4. I switch between browser tabs and return to the application tab

Result :

interactionTargetMap contains the target element of the metric and it is stored by interactionId, but when performing the described actions
firstEntry' is changing and the 'interactionId is incremented. At the same time, the metric object does not change (I could not catch changes in the values of value, delta, etc.), and the attribution is recalculated based on wrong firstEntry

Why do I think this is a bug:

the attribution object is formed based on the entry, which is not the reason for the next paint

if we assume that the event that occurred is correctly wiped by interactionTarget, then we lose the metric for analysis

@dmitiiv
Copy link
Author

dmitiiv commented Nov 19, 2024

The problem is reproduced periodically, but steadily.

@tunetheweb
Copy link
Member

I do not use Vite nor React. Can you point me to an existing MVP app that demonstrates the issue?

@dmitiiv
Copy link
Author

dmitiiv commented Nov 20, 2024

I can create the MVP app and give you a link to clone the repo or if you have better idea Im ready to do.

@tunetheweb
Copy link
Member

That works. I'm sure I could figure it out myself, but I always find it's best not to make assumptions and get an exact repro in case |I do something slightly different.

@dmitiiv
Copy link
Author

dmitiiv commented Nov 20, 2024

No problem. I'm glad to help. It happens in several applications. I can't share the private repo, that I work for. But I reproduce the problem for almost default vite + react project. I prepared the repo

link to clone
[email protected]:dmitiiv/inp-problem.git

repo link

Added description to README

@tunetheweb
Copy link
Member

OK the issue is as follows:

  1. You click on the FIRST button (or SECOND button). This does not remove the button from the screen so by the time the browser emits the Event Timing for this, the web-vitals library can still see the node and can save the target selector and element.

  2. You click About Us link. This causes a route change and the About Us link is removed from the DOM. A few milliseconds after this, the Event Timing entry is emitted and the web-vitals library can no longer see the node, and so can't save the target selector and element.

If 1 is longer than 2 then you will get a target.
If 2 is longer than 1 then you won't get a target. And, as this is a second interaction it will have a higher interactionId (by 7 since Chrome increments by 7 each time) than the first interaction.

On a fast machine they both take similar times (I'm seeing 32ms or 40ms) so they are similar but on occasion one is faster than the other. And depending which is faster you will see the intermittently. If you made the button take longer with some blocking work, then you would consistently see the target.

As noted in #477 this saving of the target is not perfect and "will not help if the element is removed before the first event entry from that interactionId is reported so is not a full solution, but will help in some cases". So to solve this we need a similar fix in the spec and then in Chrome as the library cannot do anything further than this.

TLDR this is a duplicate of #335 but the library is limited to what it can do here so we're waiting on the fix in Event Timing spec and Chrome to make further process here.

@NoriSte
Copy link

NoriSte commented Dec 12, 2024

Would collecting INP with the reportAllChanges option mitigate this problem? (then I should take care by myself of sending the INP event to our analytics tools, of course)

@mmocny
Copy link
Member

mmocny commented Dec 12, 2024

reportAllChanges reports all changes (as in, new highest overall INP) not all interactions. You often will not get any new onINP() callbacks, even when there are new interactions. So any specific route will only get an event if it has a new largest INP-- hopefully rare!

If you're just trying to add a route-URL (for attribution) to match a single page-level INP report, you really just need to know which route was active when the longest interaction happened. You probably can do this by just comparing the timestamp of the event to the timestamps of your route changes (assuming you have a log of these). Note: I would use entry.processingStart time to compare.


If you actually want a full collection of interactions per every SPA route, you really are asking for a complete "reset" of collection of interactions. web-vitals.js does not yet offer that feature. This is because it only tries to match the formal definition of CWV metrics, and INP formally does not include SPA route splitting, yet.

But you don't need to use just the INP metric. You can just measure Event Timing directly-- its quite easy! Then, you can slice and dice and reset as you please.

Here is one possible example:

function startTrackingLongestInteraction() {
  let longestInteraction = null;
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.interactionId && (!longestInteraction || entry.duration > longestInteraction.duration)) {
        longestInteraction = entry;
      }
    }
  });
  observer.observe({ type: 'event', buffered: true, durationThreshold: 0 });
  return function getLongestInteractionAndReset() {
    const current = longestInteraction;
    longestInteraction = null;
    return current;
  };
}

That's the whole library.

Usage:

const getLongestInteractionAndReset = startTrackingLongestInteraction();

// ... after time delay or after some interactions
const eventTimingEntry = getLongestInteractionAndReset();
console.log(eventTimingEntry.duration);

Then you can try to integrate into your client router (or perhaps just use navigation api).

It doesn't offer the same attribution benefits as web-vitals.js, but it does suffice for measuring events, timestamps, durations, etc...

@tunetheweb
Copy link
Member

Also web-vitals already tries to get the element details at the time the event timing entry was emitted (even if it doesn’t report it until later, if this flag is off, when the element may have since been removed). It has done this since v4.0.0 and allows us to report interactionTarget in those cases.

However if the element has been removed by the time the event timing entry is emitted, then that won’t be filled in and using reportAllChanges won’t help, and neither will using event timing directly.

So in most cases (and particularly in this example), I would not expect this to mitigate it. The only case where it might help is if your callback references interactionTargetElement to get your own selector details (rather than using the libraries interactionTarget), but even then it will only help when that still exists on the DOM when the entry is emitted.

@NoriSte
Copy link

NoriSte commented Dec 12, 2024

First of all: THANK YOU A LOT @mmocny and @tunetheweb for the detailed answers, I really appreciate it 🙏

However if the element has been removed by the time the event timing entry is emitted, then that won’t be filled in and using reportAllChanges won’t help, and neither will using event timing directly.

This is exactly what I'm trying to do, because 80% of our INP events don't have the selector (because in one of the main Preply's pages, a lot of interactions happen inside modals), so we are missing crucial data for our goal of improving the page's INP.

In the end, what I'm doing is the following:

  1. I register two onINP callbacks, a normal one and one with reportAllChanges
  2. When the users interact with the page, we immediately calculate the selector of the element the users interacted with, and we store it
  3. When INP gets worse, we set the last element the users interacted with (see point 2) as the biggest INP offender
  4. When the main onINP callback is called (typically when the page gets hidden), we add all the details of the worst INP offender to the INP metrics we track

I'm sending this to prod today/tomorrow, and I'm more than happy to share the code if someone is trying to solve the same problem 😊

@tunetheweb
Copy link
Member

Why is interactionTarget not sufficient here? As web-vitals.js gets that as soon as the event timing entry is emitted.

Or are you saying you save the details separately as part of your event handler callback (so before event timing is emitted), and then referencing it from the onINP callback?

@mmocny
Copy link
Member

mmocny commented Dec 12, 2024

You are welcome!

If you are interacting with Modals which are being closed as part of the interaction, it might well be that by the time the Event Timing entry is reported to the performance timeline, the event.target element is already removed and GC-ed.

I think the only solution at the time is to literally capture the event target as part of the event listener which will close the modal.

You can save this target in some metadata map, and then use it as a lookup for your metrics reporting. You can do this because:

  • EventTimingEntry.name == event.type
  • EventTimingEntry.startTime == event.timeStamp
  • ...so you can use [name,startTime] and [type,timeStamp] as a type of unique identifier

Here's an old example of how you might do this:
https://gist.github.com/mmocny/5914f883142429ab7df9c0197fe131ed

(Notice how mapOfTargets is set from event listeners and read from PerformanceObserver)

@NoriSte
Copy link

NoriSte commented Dec 19, 2024

Here's an old example of how you might do this: https://gist.github.com/mmocny/5914f883142429ab7df9c0197fe131ed

It makes a lot of sense, thank you!!!

FYI: the solution I implemented resulted in a low 25% accuracy in detecting the missing interactionTarget.

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

No branches or pull requests

4 participants