React rerendering too much when using YJS

228 Views Asked by At

I'm making a collaborative app using:

"next": "^13.4.12",

"yjs": "^13.6.7",
"y-webrtc": "^10.2.5",

// Slate and its YJS implementation probably aren't the issue, I think
"slate": "^0.94.1",
"slate-history": "^0.93.0",
"slate-react": "^0.98.0"
"@slate-yjs/core": "^1.0.2",
"@slate-yjs/react": "^1.1.0",

It's very early in development so I'm trying to fake writing incoming updates to a DB using a setTimeout. However, the component keeps rerendering a lot and I can't figure out why.

The grandparent and parent components, shown below, never rerender while things happen inside the child component. But they do pass a Y.Doc() object to the child, which is maybe part of the issue?

import * as Y from 'yjs';

const yDoc = new Y.Doc()

export default function GrandParentComponent() {

    const provider: WebrtcProvider = useMemo(() => new WebrtcProvider(
        'roomName',
        yDoc,
        {
            signaling: ['ws://localhost:4444']
        }
    ), [])

    yDoc.on('update', (update: any) => {
        // Logic here does not cause rerenders
    })

    return (
        <form onSubmit={(e) => e.preventDefault()}>
        
            <ParentComponent
                yDoc={yDoc}
                provider={provider}
            />

        </form>
)}

The child component below. At first, I thought I could use local state like useState to keep track of whether or not the app has saved new data to disk, but the rerendering consistently wiped it out.

That's why I've ended up using a bunch of different tricks to work around the rerendering and prevent multiple writes of the same data to disk:

  • Global variable: timeoutId keeps track of whether a save is in progress
  • Checking if latest update time is later than latest save time
  • Setting latest update time and latest save time in the same Y.Doc map object
    • However, it was the latency in setting and reading this that led to using the timeoutId global variable.

Oddly, useEffect with no dependencies doesn't run again on rerender. As I understand it, we use it this way to do things on component mount, so it should run again, right?

import * as Y from 'yjs';
import { useEffect, useState, memo, useMemo } from 'react';
import DateTimeInput from "@/components/collab-core-components/datetime-input";

type Props = {
    yDoc: Y.Doc;
    label: string;
    placeholder: string;
    contentName: string;
    isLeader: boolean;
}

let timeoutId: NodeJS.Timeout | undefined

export default memo(function ChildComponent({
    yDoc,
    label,
    placeholder,
    contentName,
}: Props) {
    const [content, setContent] = useState<Date>()

    UseEffect(() => {

        // Interestingly, this never rerenders after first mount

    },[])

    yDoc.on('update', () => {
        const conn = yDoc.getMap<string>(contentName)

        const status = conn.get('status')
        if (status === 'saving' || timeoutId) return

        const data = conn.get('data')

        // Latest time data was updated on client (not yet saved to disk)
        const updated = conn.get('updated')

        // Latest time data was saved to disk
        const saved = conn.get('saved')

        if (data === undefined) return

        const content = new Date(data)
        if (content === undefined) return

        if (saved && updated && new Date(saved) > new Date(updated)) {
            return
        }

        conn.set('status', 'saving')

        // A fake call to save data to disk
        timeoutId = setTimeout(() => {
            conn.set('status', 'saved')
            conn.set('saved', new Date().toISOString())
            timeoutId = undefined
        }, 3000);
    })


    return (
        <>
            ...
            
            // Things that happen inside `DateTimeInput` does not seem to cause rerenders
            <DateTimeInput
                yDoc={yDoc}
                contentName={contentName}
                label={label}
                placeholder={placeholder}
                content={content}
                setContent={setContent}
            />
        </>
    )
})

I doubt the DateTimeInput component causes the problem but here it is:

import * as Y from 'yjs'
import { DateTimePicker } from '@mantine/dates'
import { useState } from 'react';

export default function DateTimeInput({
    yDoc,
    contentName,
    label,
    placeholder,
    content,
    setContent,
}: Props) {

    yDoc.on('update', update => {
        const newDate = yDoc.getMap<string>(contentName).get('data')
        if (newDate === undefined) return
        setContent(new Date(newDate))
    })

    return (
        <>
            <DateTimePicker
                ...
                value={content}
                onChange={(event) => {
                    if (!event) return

                    const conn = yDoc.getMap<string>(contentName)

                    conn.set('data', event.toISOString())

                    conn.set('updated', new Date().toISOString())
                }}
            />
        </>
    )
}

I've tried out a hundred things over the last 2 days to fix this issue so I apologise if I'm not able to tell you what I've already tried. Essentially though, my current hypothesis is that yDoc has changes whenever it sets data in the map, this could cause the rerenders.

I don't think it should since displayed content only changes inside the DateTimePicker.

My Slate editor also uses a YJS implementation. It does cause the components above to rerender. However, unlike when using the components above, the rerenders caused by Slate are very few.

Am I using React and YJS wrongly somehow?

Update 5 Aug, 14:35 CET:

Based on Konrad's comment I realise I've been a little silly so I placed yDoc.on in ChildComponent into a useEffect. It seems to reduce quite a number of renders. There are still many unnecessary renders but this has pointed me to another component that could be contributing equally to the issue.

Update 6 Aug, 18:08 CET:

The y-webrtc provider is sending awareness updates with any update clients make. For some reason, this causes the top level GrandParentComponent to rerender. When I comment out the awareness code, the components stop rerendering.

As shown above, the provider is memoized.

I didn't show it above but the awareness listener is mounted with useEffect in the same component:

useEffect(() => {

    // Removing this code stops all unnecessary rerenders
    provider.awareness.on('change', (changes: any) => {
        ...
    })
}, [])

As far as I know, these should prevent unnecessary rerenders.

Not sure why/how it happens. I'd love to read more write-ups about how the awareness listener works, else I might jump into the source code at some point.

In the interest of time, I'd say, case closed for now. If anyone knows how to implement awareness properly though, I'm all ears.

0

There are 0 best solutions below