petyosi / react-virtuoso

The most powerful virtual list component for React
https://virtuoso.dev
MIT License
5.25k stars 301 forks source link

[BUG] flushSync error in latest version v2.16.6 #727

Closed jantonso closed 2 years ago

jantonso commented 2 years ago

Describe the bug We were previously using react-virtuoso@v2.10.2 with react@16.14.0 and react-dom@16.14.0, which was working great!

In a recent update, we upgraded to using the latest v2.16.6 version of react-virtuoso, which is causing the following error to be thrown when scrolling up/down a large list of items.

Screenshots Screenshot from 2022-08-10 10-50-05

Desktop (please complete the following information):

Additional context

const ChatMessageList = (props) => {

    const {
        channel,
        me,
        allUsers,
        typingUsers,
        messages,
        messageOrder,
        isGroupChat,
        isUserMember,
        roomRef,
        onLoadMessages,
        onDelete,
        onAddReaction,
        onDeleteReaction,
    } = props;

    const intl = useIntl();

    // Keep track of the index of the "first" message in the list that we have
    // loaded so far, relative to the total number of messages in the channel
    const [firstItemIndex, setFirstItemIndex] = useState(TOTAL_MESSAGE_COUNT);

    // Keep track of which message currently has keyboard focus, if any
    const [focusIndex, setFocusIndex] = useState();

    // Keep track of the the child scroll container
    const scrollRef = useRef();

    useEffect(() => {

        // Focus on individual messages using up/down arrow keys
        const handleOnKeyDown = (event) => {

            if (focusIndex !== undefined && focusIndex !== null) {
                return;
            }

            const { key } = event;

            const focusedElement = document.activeElement;
            const messageDropdownHasFocus = focusedElement && (
                focusedElement.className.includes('dropdown-item')
            );

            // Ignore if currently focused on menu dropdown icon
            if (messageDropdownHasFocus) {
                return;
            }

            switch (key) {

            // Focus on next tab (or first)
            case 'ArrowUp':
            case 'ArrowDown':

                // If we're currently focused on an individual message,
                // prevent default scroll behavior
                if (Number.isInteger(focusIndex)) {
                    event.preventDefault();
                }
                // Find the closest message of the element with the current focus
                const closestMessage = document.activeElement &&
                    document.activeElement.closest('.individual-message');

                if (closestMessage) {

                    const indexToFocus = parseInt(closestMessage.dataset.index, 10);

                    setFocusIndex(indexToFocus);

                }

                break;

            // Ignore all other key strokes
            default:
                return;

            }

        };

        const roomEl = roomRef.current;

        if (roomEl) {
            roomEl.addEventListener('keydown', handleOnKeyDown, true);
        }

        return () => {

            if (roomEl) {
                roomEl.removeEventListener('keydown', handleOnKeyDown, true);
            }

        };

    }, [roomRef, focusIndex]);

    const handleLoadMessages = () => {

        onLoadMessages().then((newMessages) => {

            if (newMessages) {

                const nextFirstItemIndex = firstItemIndex - newMessages.length;

                // Note that the first item index can't be negative
                setFirstItemIndex(Math.max(0, nextFirstItemIndex));

                // Maintain focus on the message if scroll
                // triggered with keyboard
                if (Number.isInteger(focusIndex)) {
                    setFocusIndex(newMessages.length);
                }

            }

        });

    };

    // Contains all of the messages we'll eventually render.
    const allMessages = useMemo(() => {

        const result = [];

        if (messageOrder) {

            // Group messages from the same sender around the same time...
            for (let i = 0, len = messageOrder.length; i < len; i++) {

                const prevMessage = messages[messageOrder[i - 1]];
                const message = messages[messageOrder[i]];
                const nextMessage = messages[messageOrder[i + 1]];

                // Determine whether this is the first or last message in its group
                // Messages are grouped by sender or if sent within one minute of adjacent messages
                const differentPrevUser = prevMessage && message.user_id !== prevMessage.user_id;
                const outsideOfPrevTimeframe = prevMessage && moment(message.timestamp).diff(prevMessage.timestamp, 'minutes') > 1;
                const differentNextUser = nextMessage && message.user_id !== nextMessage.user_id;
                const outsideOfNextTimeframe = nextMessage && moment(nextMessage.timestamp).diff(message.timestamp, 'minutes') > 1;

                const isFirstMessage = !prevMessage || differentPrevUser || outsideOfPrevTimeframe;
                const isLastMessage = !nextMessage || differentNextUser || outsideOfNextTimeframe;

                let sendingUser = allUsers[message.user_id];

                // Backfill missing user data for the user who sent the message
                // i.e. the user opted-out of chat and/or is missing in the enrollment data
                if (!sendingUser) {

                    sendingUser = {
                        display_name: intl.formatMessage(intlMessages.CHAT_GENERAL_TEXT_ANONYMOUS_USER)
                    };

                }

                result.push(
                    <ChatMessage
                        key={message.id}
                        message={message}
                        me={me}
                        isGroupChat={isGroupChat}
                        isUserMember={isUserMember}
                        scrollRef={scrollRef}
                        index={i}
                        focusIndex={focusIndex}
                        sendingUser={sendingUser}
                        setFocusIndex={setFocusIndex}
                        isFirstMessage={isFirstMessage}
                        isLastMessage={isLastMessage}
                        allUsers={allUsers}
                        onDelete={onDelete}
                        onAddReaction={onAddReaction}
                        onDeleteReaction={onDeleteReaction} />
                );

            }

        }

        return result;

    }, [
        me,
        messages,
        messageOrder,
        allUsers,
        focusIndex,
        intl,
        isGroupChat,
        isUserMember,
        onDelete,
        onAddReaction,
        onDeleteReaction,
    ]);

    // Bail early if messages not loaded.
    if (!messageOrder) {
        return null;
    }

    // Indicate beginning of chat history if no messages
    if (messageOrder.length === 0) {
        return (
            <div className="d-flex text-muted p-3 align-self-center justify-content-center text-center">
                <span>
                    {isChannelDM(channel) ?
                        intl.formatMessage(intlMessages.CHAT_GENERAL_HISTORY_MESSAGE_DM, { title: getChannelTitle(channel, allUsers, me) }) :
                        intl.formatMessage(intlMessages.CHAT_GENERAL_HISTORY_MESSAGE_CHANNEL)
                    }
                </span>
            </div>
        );
    }

    return (
        <div className="message-list-container">
            <Virtuoso
                role="list"
                tabIndex="-1"
                scrollerRef={(ref) => { scrollRef.current = ref; }}
                data={allMessages}
                itemContent={(index, message) => message}
                firstItemIndex={firstItemIndex}
                // Scroll to the end of the message list once mounted
                initialTopMostItemIndex={allMessages.length - 1}
                // Load more messages if scrolled to top
                startReached={handleLoadMessages}
                // Scroll to the bottom after a new message is sent
                // if the user is already scrolled close to the bottom
                followOutput={() => {

                    if (scrollRef.current) {

                        const { clientHeight, scrollHeight, scrollTop } = scrollRef.current;

                        if ((scrollHeight - scrollTop) <= (clientHeight + SCROLL_MARGIN)) {
                            return 'smooth';
                        }

                    }

                    return false;

                }}
                components={{
                    Footer: function ChatMessageListFooter() {

                        if (typingUsers) {
                            return (
                                <ChatTypingUsers users={typingUsers} />
                            );
                        }

                        return null;

                    },
                }} />
        </div>
    );

};
petyosi commented 2 years ago

This is possible, indeed, although I took precautions to not call the flushSync where it's not necessary (it's needed for react 18). Can you strip as much as possible from your logic and replicate the problem in a codesandbox?

petyosi commented 2 years ago

@jantonso I am closing this, once you provide a runnable repro, I will take a look.