Chrome V84 fixed viewport new feature, list “load more” feature will be a problem

background

Our website has a “click to load more” feature, like this

Click the button, pull the data to fill the list, and the user scrolls down to load more…

Is this a common scene? I have browsed several websites, all of which have this scenario, and the most popular Ant Design component library in China directly encapsulates this function

It was just fine until Chrome V84 came along…

To be precise, it’s Chromium 84, because the chromium kernel on the latest Edge has the same problem

One day, I got feedback from a user: after I clicked to load more, the list seemed to refresh in place and the experience was not there, like this

Online experience link, requires Chrome 84 oh >

Click to load more, the position of the button remains the same, and scroll up 😵 after the list is filled

If you look at this GIF, you might think it’s ok. In real life, more content will be loaded. This self-scrolling will make users suddenly lose the location of the item they just viewed, which will greatly damage the user experience.

Or is there a scenario for this, but should behavioral control be left to the front-end developer?

(Nonsense seems a bit much, want to see the solution to pull directly to the end of the article ~

How to find bugs/features in Chromium?

Received feedback, said is occasionally, and then part of the user frequency. So I didn’t start thinking about the browser level, I started thinking about the logic bugs in my code.

I ran through several browsers and found that some did reproduce. After confirming that my code was seamless, I wondered react 🤣

To verify that this is framed-independent, I turned off JavaScript, manually copied the list elements to the parent node, and it still stably reproduced. For the sake of rigor, I wrote a demo with native code, still able to reproduce. The e problem is with these browsers.

This night got more than 11 o ‘clock, first go back to sleep.

The next day I woke up with a clearer mind.

First confirm the version of the browser, Chrome 83 installed by my colleague is no problem, but my 84 has a problem, it seems that this Chrome update pot.

Then do an Internet search to see if anyone has a similar problem. As it happens, the day before also had a user encountered the same problem, see the code for you: chrome84 append element problem

Finally, take a look at the update documentation (until now I only knew that Chrome 84 had changed its same-site policy

There is no mention of this feature in Chrome 84.

This feature change is too small to be included in the feature list 🙂. More details need to be checked in the Commit log

It seems that I can only go to the version submission log to check, after inputting the scroll keyword, there are thousands of results jump out, really encourage me to quit, I still want to mention the bug and wait for the official solution.

scope

Browsers that currently use the Chromium 84 kernel are affected, including:

  1. Chrome 84
  2. Edge 84
  3. Android Chrome 84
  4. Android Webview (default with local Chrome upgrade, can also be maintained independently)

What, no iOS? Because iOS Chrome doesn’t use the Chromium kernel 😀

Rolling offset reset solution

Since the browser does scroll, why don’t we just “remember where we scrolled last time, load and roll back”?

I tried it, and it worked.

Complete code:

<! DOCTYPEhtml>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="Width = device - width, initial - scale = 1.0">
    <title>Document</title>
    <style>
        .container {
            width: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
        }

        .items {
            width: 100%;
        }

        .item {
            margin-top: 10px;
            height: 100px;
            width: 100%;
            background-color: #FF142B;
        }

        .btn {
            width: 200px;
            height: 44px;
            margin-bottom: 40px;
            background: #BCCFFF;
            box-shadow: 0 0 3px 0 rgba(0.0.0.0.05);
            border: none;
            border-radius: 22px;
            font-size: 15px;
            font-weight: 500;
            color: #FF142B;
            -webkit-transition: 150ms all;
            transition: 150ms all;
        }
    </style>
    <script>
        function genRandomColor() {
            const fn = () = > parseInt(Math.random() * (255 + 1), 10)
            return `rgb(${fn()}.${fn()}.${fn()}) `
        }
        function showMore() {
            let items = document.querySelector('.items')
            let tmp = document.createElement('div')
            // Remember the current position
            const currentScrollTop = document.documentElement.scrollTop || document.body.scrollTop
            
            tmp.className = "item"
            tmp.style = `background-color: ${genRandomColor()}`;
            items.appendChild(tmp)
            // Roll back to the previous position
            window.scrollTo({
                top: currentScrollTop
            })
        }
        function showMoreWithTimeout(){
            setTimeout(showMore,10)}</script>
</head>

<body>
    <div class="container">
        <div class="items">
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
        </div>
        <button class="btn" onclick="showMore()">Click to expand more</button>
    </div>
    
</body>

</html>
Copy the code

It’s ok, but there are still a few questions:

  1. When will Chrome 84 scroll internally?
  2. What happens when scrollTo is executed multiple times in an event loop?
  3. What is the difference between scrollTo in setTimeout and scrollTo in rAF?
  4. What happens when scrollTo and scrollBy are executed at the same time?

Question 1 is a little more complicated, so let’s look at some other questions

ScrollTo is executed multiple times

window.addEventListener("scroll".() = >{console.log("scroll")})
window.scrollTo(0.50) 
console.log(document.documentElement.scrollTop) / / 50
window.scrollTo(0.150) 
console.log(document.documentElement.scrollTop) / / 150

// Output a scroll
Copy the code
window.scrollTo(0.50) 
window.requestAnimationFrame(() = >{
    window.scrollTo(0.150)})// Output scroll twice
Copy the code

As can be seen from the above examples:

  • Each time scrollTo is performed, the scrollTop can be read in real time
  • No matter how many times scrollTo is executed before the Scroll event is triggered, only one Scroll event is executed, and the last scrollTop position prevails
  • ScrollTo in rAF can also trigger the Scroll event again

This conclusion can also be drawn from the event loop description of the HTML specification. In an event loop, performing the scroll step (triggering the Scroll event) occurs before rAF.

It’s worth noting, however, that interface updates are the last step in the event loop, so no matter how many scrollTo’s were performed previously, you’ll only end up seeing one scrolling update

Similarities and differences between setTimeout and rAF execution again

window.scrollTo(0.50) 
window.setTimeout(() = >{
    window.scrollTo(0.150)},0)

// Output scroll twice
Copy the code

The similarity is very simple, is that both will trigger scroll event again

The difference is that since setTimeout is another event rendering, the interface will react with two scrolling updates, which are shaken and offset to 150.

Talk about scrollTo and scrollBy

The difference is simple, absolute position scrolling and relative position scrolling, more on CSSWG

And then the scroll event is triggered at the same time as above.

  • ScrollTo (x1) followed by scrollBy(x2) and the final position isx1+x2
  • ScrollBy (x1) followed by scrollTo(x2) and the final position isx2

When will Chrome 84 scroll internally

The scrollTop output of each adjusted element is reflected in real time, so we write the following code

function getScrollTop(){
    return document.documentElement.scrollTop || document.body.scrollTop
}
function showMore() {
    let items = document.querySelector('.items')
    let tmp = document.createElement('div')
    const lastScrollTop = getScrollTop()
    console.log("lastScrollTop:",lastScrollTop) / / 529
    tmp.className = "item"
    tmp.style = `background-color: ${genRandomColor()}`;
    items.appendChild(tmp)
    console.log("currentScrollTop:",getScrollTop()) / / 639
    window.scrollTo(0,lastScrollTop) 
    console.log("changeScrollTop:",getScrollTop()) / / 529
}
Copy the code

You can see that a scrollto-like method is called internally to change the offset in the list container appendChild element. Since we restored scrollTop at the end, the internal browser changes will not be affected.

Question 1 has been answered ~😁

React processing

React code does not react properly.

Transform the above code into the React component

import React, { useState } from "react";
import "./styles.css";

function genRandomColor() {
  const fn = () = > parseInt(Math.random() * (255 + 1), 10);
  return `rgb(${fn()}.${fn()}.${fn()}) `;
}
const Item = ({ item }) = > {
  return <div className="item" style={{ backgroundColor: item.color}} / >;
};
const getScrollTop = () = > {
  return document.documentElement.scrollTop || document.body.scrollTop;
};
const fetch = async() = > {return new Promise(resolve= > {
    setTimeout(() = > {
      resolve({
        color: genRandomColor()
      });
    }, 0);
  });
};
export default function List() {
  const [list, setList] = useState(
    new Array(6).fill().map(v= > ({ color: "red"})));const showMore = async() = > {const scrollTop = getScrollTop();
    let data = await fetch(); // Wrap the promise, and all subsequent code executes asynchronously
    UseLayoutEffect and re-render are executed synchronously for asynchronously executed state changes
    setList([...list, data]);
    // Offset reset
    window.scrollTo({
      top: scrollTop
    });
  };
  return (
    <div className="container">
      <div className="items">
        {list.map((item, i) => (
          <Item item={item} key={i} />
        ))}
      </div>
      <button className="btn" onClick={showMore}>Click to expand more</button>
    </div>
  );
}

Copy the code

State changes executed asynchronously execute useLayoutEffect and re-render synchronously: In asynchronous code that is not controlled by React, such as promises or timers, the state change method is diff and rerender internally instead of being updated after all the state change methods have been executed.

More examples
  useLayoutEffect(() = > {
    console.log("useLayoutEffect");
  });
  const showMore = async () => {
    setLoading(true);
    console.log(0); // output: 0
    // State changes are handled during an asynchronous wait
    // output: useLayoutEffect
    let data = await fetch();
    console.log(1); // output:1
    setList([...list, data]); // output: useLayoutEffect
    console.log(2); // output:2
    setLoading(false); // output: useLayoutEffect
    console.log(3); // output:3
  };
Copy the code

The output is:

0
useLayoutEffect 
1
useLayoutEffect 
2
useLayoutEffect 
3
Copy the code

For synchronous functions, setList execution is asynchronous, so window.scrollto cannot be done immediately

const showMore = () = > {
    const scrollTop = getScrollTop();
    let data = { color: genRandomColor() };
    setList([...list, data]);
    / / is invalid
    window.scrollTo({
        top: scrollTop
    });
};
Copy the code

We need to write a method that supports both asynchronous and synchronous updates and package it as react Hook for reuse

function useScrollReset() {
  const lastScrollTopRef = useRef(0);
  const [scrollTop, setScrollTop] = useState(0);
  useLayoutEffect(() = > {
    console.log("async: chromium v84+ need reset scroller");
    window.scrollTo({
      top: scrollTop
    });
  }, [scrollTop]);
  const remainLastScrollTop = useCallback(() = >{ lastScrollTopRef.current = getScrollTop(); } []);const resetScroller = useCallback(isAsyncStateChange= > {
    if (isAsyncStateChange) {
      // Suitable for asynchronous state change scenarios
      setScrollTop(lastScrollTopRef.current);
    } else {
      // Suitable for synchronous state change scenarios
      console.log("sync: chromium v84+ need reset scroller");
      window.scrollTo({
        top: lastScrollTopRef.current }); }} []);return [remainLastScrollTop, resetScroller];
}
Copy the code

Export two methods. The first method remainLastScrollTop is used before the list is populated with data items and is used to remember the current scroll position. The second method processes the change in data state by passing in the corresponding Boolean value, depending on whether it is asynchronous or not.

The two examples above, for example, should be used this way

  const showMore = async() = > {let data = await fetch();
    remainLastScrollTop();
    // The state is changed synchronously and has been rerendered after execution
    setList([...list, data]);
    // So set false to directly adjust the progress bar
    resetScroller(false);
  };

  const showMore = () = > {
    let data = { color: genRandomColor() };
    remainLastScrollTop();
    // The status changes asynchronously, so adjusting the progress bar will have to wait until useLayoutEffect
    setList([...list, data]);
    // So set true to change scroll state so that useLayoutEffect will be executed later
    resetScroller(true);
  };
Copy the code

The online demo

Plus browser judgment

The initial thought was that other browsers were worried that “browser offset resets” would cost too much performance, so I added the following judgment

import Bowser from 'bowser'
const browserInfo = Bowser.getParser(window.navigator.userAgent)
const needReset = browserInfo.satisfies({
    chrome: '> = 84'
})
function useScrollReset() {
  const lastScrollTopRef = useRef(0);
  const [scrollTop, setScrollTop] = useState(0);
  useLayoutEffect(() = > {
    console.log("async: chromium v84+ need reset scroller");
    window.scrollTo({
      top: scrollTop
    });
  }, [scrollTop]);
  const remainLastScrollTop = useCallback(() = >{ lastScrollTopRef.current = getScrollTop(); } []);const resetScroller = useCallback(isAsyncStateChange= > {
    if (isAsyncStateChange) {
      // Suitable for asynchronous state change scenarios
      setScrollTop(lastScrollTopRef.current);
    } else {
      // Suitable for synchronous state change scenarios
      console.log("sync: chromium v84+ need reset scroller");
      window.scrollTo({
        top: lastScrollTopRef.current }); }} []);return [remainLastScrollTop, needReset?resetScroller:() = >{}];
}
Copy the code

Later I thought it was not necessary, and this maintainability is very poor, if the back domestic browser support chromium, here may have to change, and the top one has not added Edge, so it was removed.

New solution found in Ant Design List Load more demo

When finding the solution, think about whether the component library is the processing of this update, if not, can be a pr wave

So I opened the and Design component documentation and tried the List Load More demo

To my surprise, the demo works properly with “click to load more”

Could the official team have discovered the problem and fixed it?

After looking at the commit logs and source code for the related components, I rejected this idea and determined that the problem was with the demo

import React from 'react';
import ReactDOM from 'react-dom';
import 'antd/dist/antd.css';
import './index.css';
import { List, Avatar, Button, Skeleton } from 'antd';

import reqwest from 'reqwest';

const count = 3;
const fakeDataUrl = `https://randomuser.me/api/? results=${count}&inc=name,gender,email,nat&noinfo`;

class LoadMoreList extends React.Component {
  state = {
    initLoading: true.loading: false.data: [].list: [],};componentDidMount() {
    this.getData(res= > {
      this.setState({
        initLoading: false.data: res.results,
        list: res.results,
      });
    });
  }

  getData = callback= > {
    reqwest({
      url: fakeDataUrl,
      type: 'json'.method: 'get'.contentType: 'application/json'.success: res= >{ callback(res); }}); }; onLoadMore =() = > {
    this.setState({
      loading: true.list: this.state.data.concat([...new Array(count)].map(() = > ({ loading: true.name: {}}))),});this.getData(res= > {
      const data = this.state.data.concat(res.results);
      this.setState(
        {
          data,
          list: data,
          loading: false,},() = > {
          // Resetting window's offsetTop so as to display react-virtualized demo underfloor.
          // In real scene, you can using public method of react-virtualized:
          // https://stackoverflow.com/questions/46700726/how-to-use-public-method-updateposition-of-react-virtualized
          window.dispatchEvent(new Event('resize')); }); }); };render() {
    const { initLoading, loading, list } = this.state;
    constloadMore = ! initLoading && ! loading ? (<div
          style={{
            textAlign: 'center',
            marginTop: 12.height: 32.lineHeight: '32px'}} >
          <Button onClick={this.onLoadMore}>loading more</Button>
        </div>
      ) : null;

    return (
      <List
        className="demo-loadmore-list"
        loading={initLoading}
        itemLayout="horizontal"
        loadMore={loadMore}
        dataSource={list}
        renderItem={item= > (
          <List.Item
            actions={[<a key="list-loadmore-edit">edit</a>.<a key="list-loadmore-more">more</a>]}
          >
            <Skeleton avatar title={false} loading={item.loading} active>
              <List.Item.Meta
                avatar={
                  <Avatar src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png" />
                }
                title={<a href="https://ant.design">{item.name.last}</a>}
                description="Ant Design, a design language for background applications, is refined by Ant UED Team"
              />
              <div>content</div>
            </Skeleton>
          </List.Item>)} / >
    );
  }
}

ReactDOM.render(<LoadMoreList />.document.getElementById('container'));
Copy the code

As you can see, the button is removed when the data is loaded. Because loading placeholders are used, removing the button does not make the layout look wobbly

Let’s change the code slightly and simplify it as follows:

import React from 'react';
import ReactDOM from 'react-dom';
import 'antd/dist/antd.css';
import './index.css';
import { List, Avatar, Button, Skeleton } from 'antd';

import reqwest from 'reqwest';

const count = 3;
const fakeDataUrl = `https://randomuser.me/api/? results=${count}&inc=name,gender,email,nat&noinfo`;

class LoadMoreList extends React.Component {
  state = {
    loading: false.data: [].list: [],};componentDidMount() {
    this.getData(res= > {
      this.setState({
        data: res.results,
        list: res.results,
      });
    });
  }

  getData = callback= > {
    reqwest({
      url: fakeDataUrl,
      type: 'json'.method: 'get'.contentType: 'application/json'.success: res= >{ callback(res); }}); }; onLoadMore =() = > {
    this.setState({
      loading: true
    });
    this.getData(res= > {
      const data = this.state.data.concat(res.results);
      this.setState(
        {
          data,
          list: data,
          loading: false}); }); };render() {
    const { loading, list } = this.state;
    constloadMore = ! loading ? (<div
          style={{
            textAlign: 'center',
            marginTop: 12.height: 32.lineHeight: '32px'}} >
          <Button onClick={this.onLoadMore}>loading more</Button>
        </div>
      ) : null;

    return (
      <>
      <List
        className="demo-loadmore-list"
        itemLayout="horizontal"
        dataSource={list}
        renderItem={item= > (
          <List.Item
            actions={[<a key="list-loadmore-edit">edit</a>.<a key="list-loadmore-more">more</a>]}
          >
            <Skeleton avatar title={false} loading={item.loading} active>
              <List.Item.Meta
                avatar={
                  <Avatar src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png" />
                }
                title={<a href="https://ant.design">{item.name.last}</a>}
                description="Ant Design, a design language for background applications, is refined by Ant UED Team"
              />
              <div>content</div>
            </Skeleton>
          </List.Item>
        )}
      />
      {loadMore}
      </>
    );
  }
}

ReactDOM.render(<LoadMoreList />.document.getElementById('container'));
Copy the code

After clicking, the button is removed, so the list is at the bottom, and after filling in the list and showing the button, the list returns to its original position.

In addition, after loading is removed, i.e

const loadMore = <div
    style={{
      textAlign: 'center',
      marginTop: 12.height: 32.lineHeight: '32px'}} >
    <Button onClick={this.onLoadMore}>loading more</Button>
  </div>;
Copy the code

Click load more bugs reappear, and there is a new offset jitter due to the delete button.

From this, we can draw a conclusion:

If there is no element at the bottom of the list (either remove or display None, as long as it does not occupy space), the browser will not adjust the scroll offset of the top content itself

More specifically, “the element that triggered the click and other events up to the level above the list container” is removed, and other “elements below the level” are not processed

So, just hide the button when it’s loaded and show it back when it’s loaded. However, hiding the button will change the layout. If there is no loading item placeholder, the data list will be rolled down at first, which also affects the experience.

When you put it all together, you come up with a trick: after you get the data, append it to the list while hiding the button, and display it immediately after you populate it. Tricking the browser into rendering as if nothing happened.

The native code is as follows:

GetScrollTop () = getScrollTop()
function showMore() {
    let items = document.querySelector('.items')
    let btn = document.querySelector('.btn')
    let tmp = document.createElement('div')
    const lastScrollTop = getScrollTop()
    console.log("lastScrollTop:",lastScrollTop)
    tmp.className = "item"
    tmp.style = `background-color: ${genRandomColor()}`;
    // Hide it first
    btn.style.display = "none";
    items.appendChild(tmp)
    console.log("currentScrollTop:",getScrollTop())
    // redisplay
    btn.style.display = "block";
    console.log("changeScrollTop:",getScrollTop())
}
Copy the code

React:

export default function List() {
  const [loading, setLoading] = useState(false);
  const [list, setList] = useState(
    new Array(6).fill().map(v= > ({ color: "red"}))); useLayoutEffect(() = > {
    console.log("useLayoutEffect");
  });
  const showMore = async() = > {let data = await fetch();
    // Each state is re-render
    setLoading(true);
    setList([...list, data]);
    setLoading(false);
  };
  return (
    <div className="container">
      <div className="items">
        {list.map((item, i) => (
          <Item item={item} key={i} />
        ))}
      </div>{! loading && (<button className="btn" onClick={showMore}>Click to expand more</button>
      )}
    </div>
  );
}
Copy the code

How to simulate the fixed viewport effect of Chrome V84

The effect is a bit like a pull-down refresh scene on mobile, so I guess this update is probably for mobile.

Then again, how can other browsers emulate this functionality?

Some of the things you can think of are:

  1. Use MutationObserver to listen for DOM changes (new elements), calculate the difference between before and after offsetHeight, and use scrollBy for scroll offset
  2. Click offsetTop on the before and after Record button, then calculate the difference and use scrollBy to scroll offset

The second option is demo

<! DOCTYPEhtml>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="Width = device - width, initial - scale = 1.0">
    <title>Document</title>
    <style>
        .container {
            width: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
        }

        .items {
            width: 100%;
        }

        .item {
            margin-top: 10px;
            height: 100px;
            width: 100%;
            background-color: #FF142B;
        }

        .btn {
            width: 200px;
            height: 44px;
            margin-bottom: 40px;
            background: #BCCFFF;
            box-shadow: 0 0 3px 0 rgba(0.0.0.0.05);
            border: none;
            border-radius: 22px;
            font-size: 15px;
            font-weight: 500;
            color: #FF142B;
            -webkit-transition: 150ms all;
            transition: 150ms all;
        }

        .bottom {
            width: 100%;
            height: 1200px;
        }
    </style>
    <script>
        function genRandomColor() {
            const fn = () = > parseInt(Math.random() * (255 + 1), 10)
            return `rgb(${fn()}.${fn()}.${fn()}) `
        }
        function getScrollTop() {
            return document.documentElement.scrollTop || document.body.scrollTop
        }
        function showMore() {
            let items = document.querySelector('.items')
            let btn = document.querySelector('.btn')
            let tmp = document.createElement('div')
            const lastOffsetTop = btn.offsetTop
            console.log("lastOffsetTop:", lastOffsetTop)
            tmp.className = "item"
            tmp.style = `background-color: ${genRandomColor()}`;
            items.appendChild(tmp)
            // Put it in rAF to prevent backflow when loading offsetTop. The browser renders items immediately and then adjusts scroll to flash the page
            requestAnimationFrame(() = > {
                const currentOffsetTop = btn.offsetTop
                console.log("currentOffsetTop:", currentOffsetTop)
                window.scrollBy({
                    top: currentOffsetTop - lastOffsetTop
                })
            })
        }
        function showMoreWithTimeout() {
            setTimeout(showMore, 10)}</script>
</head>

<body>
    <div class="container">
        <div class="items">
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
            <div class="item"></div>
        </div>

    </div>
    <button class="btn" onclick="showMore()">Click to expand more</button>
    <div class="bottom">Fixed depending on the area</div>
</body>

</html>
Copy the code

Note here that in order to prevent reflux redrawing, it is processed in rAF

If there is a better way to do this, please share in the comments

conclusion

Chrome V84 + is a new feature of the browser specific fixed viewport, but at the same time creates a “click to load more” scenario that will not meet expectations.

In order to solve this problem, this paper proposes two solutions, which are as follows:

  1. Roll offset reset
  2. Hide elements below

Both work for most scenarios, but have their limitations:

  • The first resets the scrollbar, so if after clicking the button, the user continues scrolling far down the list while it is unfilled; After the list is filled, the scroll bar is reset, and the experience is also bad
  • The layout of the second type is not fully understood, and it is not yet clear whether there will be some kind of layout limitation

For now, it’s best to take a look at how the kernel source code is handled and keep an eye on the progress of the bug 👻

Current status: Officially confirmed

Finally, we try to investigate the implementation of this feature for a rainy day