2

Ok guys and gals, here's a tricky one for all you wizards...

I'm working on an immersive web app that overrides the default touch scrolling behavior on mobile devices. The content is divided into pages that use 100% of the viewport and navigation is handled by swiping up and down between pages.

On the first swipe I call requestFullscreen() on the body element which, of course, causes a reflow as the viewport resizes. The problem is that I also want that first swipe to trigger the custom scrolling behavior but I'm using Element.nextElementSibling.scrollIntoView({ block : start, behavior : 'smooth' }) and until the reflow is finished the top edge of the next page (an HTMLSectionElement) is already visible so the scroll doesn't happen.

If I use setTimeout to wait about 600ms until the reflow is finished the scroll effect works as expected but I'm not happy with this hacky workaround and I'd prefer to use a more elegant async solution.

I first tried triggering the scroll effect from inside the the resolve executor of the Promise returned by requestFullscreen but that didn't help. This promise resolves very early on in the execution flow.

Then I tried from inside a fullscreenchange event handler. No luck here either as this event is fired immediately before the fullscreen change happens.

Lastly I tried from inside a window resize event handler but this fires before the reflow happens. I added a requestIdleCallback here too but it didn't make any difference.

So my question is... Is there any reliable way to detect the end of a reflow operation? Or alternatively... does anybody have a better Plan B than giving up on using scrollIntoView and coding my own scroll effect into a window resize handler.

Besworks
  • 4,123
  • 1
  • 18
  • 34
  • The *reflow* operation is synchronous. What may not be is when it gets called. See [this answer of mine](https://stackoverflow.com/questions/47342730/javascript-are-dom-redraw-methods-synchronous/47343090#47343090). So, no, you are not really looking "to detect the end of a reflow operation". I'm not sure what you are looking for though, because your question lacks a [MCVE]. Maybe you are rather willing to "force" a reflow, so that the CSSOM is up-to-date when you start scrolling, or you are willing to detect when the resizing ends? But you are not asking the correct question. – Kaiido Mar 16 '21 at 02:14
  • 2
    I asked this almost 2 years ago about a specific problem I had with a web-app that I was working on at the time. Unfortunately `Fullscreen API` requests don't work inside `iframe` elements so adding a working snippet of the issue wasn't feasible. The specific issue I had 2 years ago no longer matters anyway though so what I'll probably do is rewrite this to be a more generic and useful question for a wider audience. – Besworks Mar 16 '21 at 02:34

2 Answers2

5

Ok future googlers, I'm back a couple years later with a REAL, non-hacky answer to this problem.

The trick is to use a two-step requestAnimationFrame chain inside a ResizeObserver. The first callback is triggered right before the reflow occurs. You can use this callback this to make any last-second changes to the DOM. Then, inside this callback, we use requestAnimationFrame again to specify another callback which will happen after the paint from the previous frame.

You can verify the timing of this method by uncommenting the debugger; lines in the example code below.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="description" content="React to element size changes after layout reflow has occured.">
    <title> Reflow Observer Test </title>
    <style>
      * { box-sizing: border-box; }
      html, body { height: 100vh; margin: 0; padding: 0; }
      body { background: grey; }
      body:fullscreen { background: orange;}
      body:fullscreen::backdrop { background: transparent; }
      #toggle { margin: 1rem; }
    </style>
  </head>
  <body>
    <button id="toggle"> Toggle Fullscreen </button>
    <script>
      let resizableNode = document.body;
      
      resizableNode.addEventListener('reflow', event => {
        console.log(event.timeStamp, 'do stuff after reflow here');
      });
      
      let observer = new ResizeObserver(entries => {
        for (let entry of entries) {
          requestAnimationFrame(timestamp => {
            // console.log(timestamp, 'about to reflow'); debugger;
            requestAnimationFrame(timestamp => {
              // console.log(timestamp, 'reflow complete'); debugger;
              entry.target?.dispatchEvent(new CustomEvent('reflow'));
            });
          });
        }
      });
      
      observer.observe(resizableNode);
      
      function toggleFullscreen(element) {
        if (document.fullscreenElement) {
          document.exitFullscreen();
        } else {
          element.requestFullscreen();
        }
      }
      
      document.getElementById('toggle').addEventListener('click', event => {
        toggleFullscreen(resizableNode);
      });
    </script>
  </body>
</html>

In this example I'm using going fullscreen on the body element as my trigger but this could easily applied to any resize operation on any DOM node.

Besworks
  • 4,123
  • 1
  • 18
  • 34
  • 2
    I've wrapped this up into a nice tidy ES6 class, available here https://github.com/besworks/ReflowObserver – Besworks Mar 16 '21 at 01:27
  • 1
    Why wait a full frame again? Just wait a single task, or even better use `requestPostAnimationFrame` where available, see [this answer of mine](https://stackoverflow.com/questions/58088388/why-is-requestanimationframe-running-my-code-at-the-end-of-a-frame-and-not-at/58090066#58090066). Also beware, all browsers don't trigger the *auto*-reflow at the same time. For instance Safari will call it in idle even before rAF fires, so in that browser if you mess up with the CSSOM in rAF you actually force a new, unnecessary reflow right before the painting. – Kaiido Mar 16 '21 at 02:08
  • Excellent notes @kaiido, I hadn't heard of `requestPostAnimationFrame`. This does indeed work in place of the second `requestAnimationFrame` when I tested in Chrome 89. For my use-case I'm only worried about Chromium based browsers but your notes about timing in other browsers may help others narrow down their issues. – Besworks Mar 16 '21 at 02:19
  • Oh and if you are targeting only Chromium browsers, beware their `requestAnimationFrame` implementation is [completely broken](https://stackoverflow.com/questions/50895206/exact-time-of-display-requestanimationframe-usage-and-timeline/57549862#57549862). If you don't call rAF from an animated document, you may not be before the next paint at all. – Kaiido Mar 16 '21 at 02:23
  • Helped a lot, thank you! – fires3as0n Nov 17 '21 at 20:04
2

In case anyone would like to know, I handled this situation by firing my scroll effect from a window resize event listener though a debounce proxy function. It still uses setTimeout but continuously resets the counter to ensure that the scroll happens after all resize events have fired (4 in the case of Chrome).

It's not the most elegant solution and the scroll effect has the potential to be delayed more than absolutely necessary but at least the scroll will never be blocked by the reflow and I can live with that.

Hopefully one day we'll have a ReflowEndEvent event or something similar that we can listen for.

Besworks
  • 4,123
  • 1
  • 18
  • 34