Skip to content
This repository has been archived by the owner on Jun 10, 2020. It is now read-only.

Rapid glitching on demo page #248

Open
duhaime opened this issue Nov 4, 2017 · 10 comments
Open

Rapid glitching on demo page #248

duhaime opened this issue Nov 4, 2017 · 10 comments

Comments

@duhaime
Copy link

duhaime commented Nov 4, 2017

Just FYI, the demo page displays a strange rapid glitch when a user has scrolled to a particular location on the page:

out

As you can see at the end of the gif, the glitching disappears if one scrolls up (or down) just slightly from the mysterious spot.

This was observed in recent Chrome on OSX.

window.innerWidth: 761
window.innerHeight: 776
window.scrollY: 981
@bj7
Copy link

bj7 commented Jan 4, 2018

I can confirm this behavior in my local dev environment. It appears that the scroll event is caught in some loop. This also makes anything un-clickable while the glitch occurs.

@ido1wald
Copy link

The source of the bug:
every scroll event callback is calling .setState which reRender the component. and it turns two problems:

  1. sometimes the state hasn't change or hasn't change much. but still it calls the reRender, for example:
    if prev state were: state = {myState : "ok"} and in a callback function you called the function: this.setState({myState : "ok"}); it will reRender the screen.
  2. There's not enough efficiency in the code, for example: if the list shows 10 records at the time, the list should "show" 30 items (20 of them will be hidden of course because of overflow), and only render another 30 (and removing the last 30) when the user scrolled over more than 10 items.
    it makes the view more smooth and faster and prevent this jumps

@BlackSkyJY
Copy link

I also found this problem in the project. Do you have any good ways?

@KendallWeihe
Copy link

I can confirm this behavior is happening to a project of mine as well. @ido1wald seems to be onto something. I'm not handling any events. Anyone has a solution to this?

@Timopheym
Copy link

@duhaime @bj7 @ido1wald @BlackSkyJY @KendallWeihe Is anyone found any solution? I have it too and it's blocker, and I even can't find any hack-around...

@idowald
Copy link

idowald commented May 31, 2018

@Timopheym sorry brother, we re-created this entire component in our private repo, from scratch.
it isn't easy task, just don't use this library if it's important to you

@Timopheym
Copy link

@ido1wald Godness... is there any open-source alternative or may be not buggy tag of this one?...

@duhaime
Copy link
Author

duhaime commented May 31, 2018

@Timopheym I too would roll my own. If you did and it were general enough you could open source it and carry the mantle!

@Timopheym
Copy link

@duhaime I managed it with integration of https://github.com/bvaughn/react-virtualized/ currently i have some issues, but i hope to make well.

@x-yuri
Copy link

x-yuri commented Jul 29, 2018

The idea behind this component is surely interesting. At any given moment only some of the items are mounted. The rest are replaced by two divs (top and bottom spacers) of height the corresponding items would take. Initially only, say, the first 5 is mounted (topSpacerHeight == 0). As you scroll down the 1st gets unmounted, the 6th gets mounted. Then, the 2nd, and the 7th. And so on.

This component might suit well cases where heights of the items are known in advance. But when they aren't, especially when the items contain images (not to mention videos), it takes much more effort to make it work.

What's the big deal about providing accurate heights of the items? The thing is that otherwise every time you cross the line where the first item (of the mounted bunch) gets unmounted, and the next one (after the mounted bunch) gets mounted, the thumb of the scrollbar twitches. Since topSpacerHeight + displayedItemsHeight + bottomSpacerHeight changes.

Also if you mess up to provide react-infinite with exact heights, you're bound to experience the effect described in this issue. You cross the line. Scroll handler notices this, and arranges for mounted items window to move down one item (first item unmounted, next mounted). As a result, height of the list of the items changes (inaccurate heights). For some reason scroll event gets triggered here. And the handler notices the line has been crossed back. Therefore it makes the window (list of mounted items) move up. This goes on until you drag the thumb (of the scrollbar) away from this position.

Let's see what issues I've faced. Imagine a list of posts, handled by two components: Posts, and Post. Posts contain images, are separated by horizontal lines and some space:

Posts.js:

import React from 'react';
import Infinite from 'react-infinite';
import Post from './Post';

const defaultHeight = 300;

export default class Posts extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            posts: [],
            isInfiniteLoading: false,
            elementHeights: defaultHeight,
        };
        this.noMorePosts = false;
        this.offset = 0;
        this.pageSize = 5;
        this.postHeights = {};
    }

    componentDidMount() {
        this.fetchPosts();
    }

    fetchPosts() {
        api(this.offset, this.pageSize).then(({data}) => {
            const newElementHeights = Array(data.length).fill(defaultHeight);
            this.setState(prevState => ({
                posts: prevState.posts.concat(data),
                elementHeights: typeof prevState.elementHeights === 'number'
                    ? newElementHeights
                    : prevState.elementHeights.concat(newElementHeights),
                isInfiniteLoading: false,
            }));
            this.noMorePosts = data.length === 0;
        })
        this.offset += this.pageSize;
    }

    // posts report their heights to the Posts component
    handlePostHeight = (id, i, height) => {
        if (id in this.postHeights)
            return;
        this.postHeights[id] = height;
        if (typeof this.state.elementHeights === 'number')
            this.setState({elementHeights:
                Array(this.state.posts.length).fill(defaultHeight)
                    .fill(height, i, i + 1)});
        else
            this.setState(prevState => ({elementHeights:
                prevState.elementHeights.fill(height, i, i + 1)}));
    }

    handleInfiniteLoad = () => {
        if (this.state.isInfiniteLoading || this.noMorePosts)
            return;
        this.setState({isInfiniteLoading: true});
        this.fetchPosts();
    }

    // not a good way to use callback refs probably
    handleCallbackRef = post => {
        if ('postSpacingHeight' in this)
            return;
        this.postSpacingHeight = 0;   // this line makes it reproducable (inaccurate height)
        if (post.props.i !== 0) {
            // here we get reference to non-first post
            // the one which has, say, margin-top
            // or in other words, non-zero spacing height
            this.postSpacingHeight = post.getSpacingHeight();
        }
    }

    render() {
        return <div>
            <Infinite elementHeight={this.state.elementHeights}
            onInfiniteLoad={this.handleInfiniteLoad}
            infiniteLoadBeginEdgeOffset={200}
            useWindowAsScrollContainer={true}
            isInfiniteLoading={this.state.isInfiniteLoading}>
                {this.state.posts.map((p, i) =>
                    <Post i={i} key={p.id} attrs={p}
                    minHeight={
                        /* set min-height after height is known
                        to set it right away next time it mounts
                        to not wait for height to be calculated
                        to not wait for image in a post to load */
                        p.id in this.postHeights
                            ? this.postHeights[p.id] - this.postSpacingHeight || 0 + 'px'
                            : 'auto'
                    }
                    onPostHeight={this.handlePostHeight}
                    ref={this.handleCallbackRef}/>
                )}
            </Infinite>
        </div>;
    }
}

const heights = [4, 89, 166, 315, 436, 23, 153, 337, 157, 340, 563, 417, 315, 351, 28, 156, 64, 123, 555, 578, 501, 218, 429, 559, 229, 213, 243, 346, 119, 521, 142, 102, 534, 313, 398, 385, 407, 230, 307, 472, 26, 374, 411, 495, 303, 397, 369, 232, 75, 340];

function api(offset, pageSize) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve({
                data: heights.slice(offset, offset + pageSize).map((h, i) => ({
                    id: offset + i + 1,
                    url: `http://via.placeholder.com/300x${h}`,
                }))
            });
        }, Math.random() * 2000);
    });
}

Post.js:

import React from 'react';
import imagesLoaded from 'imagesloaded';
import computedStyle from 'computed-style';
import classNames from 'classnames';

import './Post.css';

export default class Post extends React.Component {
    constructor(props) {
        super(props);
        this.selfRef = React.createRef();
        this.wrapperRef = React.createRef();
    }

    componentDidMount() {
        this.mounted = true;
        imagesLoaded(this.selfRef.current, () => {
            if (this.mounted) {   // image might finish loading after component has unmounted
                this.props.onPostHeight(this.props.attrs.id, this.props.i,
                    this.selfRef.current.offsetHeight);
            }
        });
    }

    componentWillUnmount() {
        this.mounted = false;
    }

    getSpacingHeight() {
        return this.selfRef.current.offsetHeight
            - (this.wrapperRef.current.offsetHeight
                - parseFloat(computedStyle(this.wrapperRef.current, 'border-top-width'))
                - parseFloat(computedStyle(this.wrapperRef.current, 'padding-top')));
    }

    render() {
        return <div
        className={classNames('post', this.props.i === 0 && 'first')}
        data-i={this.props.i} ref={this.selfRef}>
            <div className="wrapper" ref={this.wrapperRef} style={{
                minHeight: this.props.minHeight,
            }}>
                <img src={this.props.attrs.url} alt=""/>
            </div>
        </div>;
    }
}

Post.css:

.post {
    padding-top: 20px;
}
.post.first {
    padding-top: 0;
}
.wrapper {
    border-top: 1px solid #999;
    padding-top: 20px;
}
.post.first .wrapper {
    border-top: none;
    padding-top: 0;
}
.post img {
    display: block;
    margin: auto;
}

Click down arrow until it starts to twitch. You can find the needed files in this archive.

Do note, every time heights change (add, edit, delete), you've got to make them known to react-infinite.

I seem to have managed to make it work, but the code is way too complex. I decided, that if the user scrolls that much that it affects performance, the issue is either with the user (doesn't know when to give up), or with the user interface (lack of appropriate filters). So I'm going to try one of these.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants