DEV Community

Vladimir Elchinov for Session Replay

Posted on

The Button Moved Before Your Finger Landed, and Your Click Handler Is Innocent

The ticket says: "I pressed Cancel and it deleted my draft."

You open the code. The Cancel handler cancels. The Delete handler deletes. Nothing is crossed, nothing is mislabelled, and the click that arrived went to Delete because Delete was genuinely under that finger at the moment it landed. Both of you are describing the same event correctly and neither description contains the cause.

Something above those buttons changed size about eighty milliseconds earlier.

The browser already has a defence, and you have probably never configured it

There is a feature aimed squarely at this class of problem. MDN:

Scroll anchoring behavior adjusts scroll position to minimize content shifts.

And the line that explains why you have never thought about it:

Scroll anchoring behavior is enabled by default in any browser that supports it.

It works silently, so it is invisible until it is absent. Two things about it are worth holding.

It is one line to switch off. overflow-anchor: none opts an element out: in MDN's words, that element "won't be selected as a potential anchor". That is a reasonable line to write when you are fighting a different scroll bug, and a very easy line to leave behind. Nothing warns you that a protection is now off, because nothing announced it was on.

Its job is the scroll position. Read the definition again: it adjusts scroll position to minimise shifts. That keeps what somebody is reading where they are reading it. It is not, and does not claim to be, a promise that the element under a pointer at one instant is the same element 80ms later.

The measurement has a blind spot exactly where it hurts

Here is the part that costs you something.

Layout shifts are reported through LayoutShift entries, and each one carries hadRecentInput. MDN defines it precisely:

returns true if lastInputTime is less than 500 milliseconds in the past

Shifts flagged that way are normally dropped, and the reasoning is sound as far as it goes:

Layout shifts are only a problem if the user is not expecting them, so layout shifts that are the result of user interactions (such as a user expanding a UI element) are often not considered in layout shift metrics.

That is correct for a user opening an accordion. But notice what the rule is made of: a 500 millisecond window bracketing user input. A mis-tap is, by definition, an event where a shift and an interaction happened within milliseconds of each other. The filter is not wrong, it is simply blind in the one window where this particular failure lives.

So a dashboard can be green while this happens, and the green is not a lie. It is an answer to a different question.

Record the shifts you are told to ignore

The fix is not to distrust the metric. It is to keep a second, unfiltered copy for debugging:

new PerformanceObserver((list) => {
  for (const shift of list.getEntries()) {
    // Deliberately NOT filtering on hadRecentInput: those are the interesting ones here.
    recent.push({
      at: Math.round(shift.startTime),
      value: shift.value,
      nearInput: shift.hadRecentInput
    });
  }
}).observe({ type: 'layout-shift', buffered: true });
Enter fullscreen mode Exit fullscreen mode

Keep the last handful in memory and attach them to whatever you already send when something goes wrong. A click log that says deleteDraft at 12840ms is ambiguous. The same log beside a shift of 0.03 at 12795ms is not ambiguous at all: something moved 45 milliseconds before the click, and you now have a timestamp to go and look at.

Why the report will never contain this

Nobody writes "the layout moved as I was tapping". They do not perceive it as movement. They perceive it as having pressed the wrong thing, or as the application doing something they did not ask for, and the report is written in those terms because that is the honest account of their experience.

The information that would settle it is the position of two elements during a window of about a tenth of a second, on their device, at their connection speed, with whatever loaded late that day. It is not in your logs, it is not in their description, and by the time anybody looks the page has settled into the layout you would have predicted.

Which is the general shape: the evidence that decides this bug has a shorter half-life than the time it takes anybody to notice there is a bug.

Top comments (0)