'Rerender and useDeepCompareEffect don't seem to work together well

The answer to this question may very well be "don't do that", but I'd like to understand the behavior better.

I have a simple tree component that I want to rerender if the node list provided changes. The main use case is when the node list comes from a async call to the server. This is the relevant component file, minus the imports:

const TreeViewContainer = styled.div.attrs({
    'data-testid': 'tws-tree',
    role: 'tree',
})`
    padding: 0;
`;

type TreeViewAction = {
    type: 'refresh' | 'toggle';
    payload: ITreeNode | ITreeNode[];
};

const treeViewReducer = (state: ITreeNode[], action: TreeViewAction) => {
    switch (action.type) {
        // Toggle open/closed on the parent (the node passed in) and visibility on that node's children.
        case 'toggle': {
            const incomingNode = action.payload as ITreeNode;
            const isOpen = !incomingNode.open || false;
            const newNode = { ...incomingNode, open: isOpen };
            const childNodes = state.filter((n) => n.parentId === incomingNode.nodeId).slice(0);
            childNodes.forEach((node) => (node.visible = isOpen));
            return state.map((node) => {
                if (node.nodeId === newNode.nodeId) {
                    return newNode;
                } else {
                    return childNodes.find((n) => n.nodeId === node.nodeId) || node;
                }
            });
        }
        case 'refresh': {
            return cloneDeep(action.payload) as ITreeNode[];
        }
        default:
            return state;
    }
};

export function TreeView(props: ITreeView) {
    const { nodes, onNodeSelected, renderFactory } = props;
    const [nodeList, dispatch] = useReducer(treeViewReducer, nodes);

    useDeepCompareEffect(() => {
        dispatch({ type: 'refresh', payload: nodes });
    }, [nodes]);

    const handleNodeClick = (node: ITreeNode) => {
        if (node.hasChildren) {
            dispatch({ type: 'toggle', payload: node });
        } else {
            onNodeSelected(node);
        }
    };

    return (
        <TreeViewContainer>
            {nodeList
                ?.filter((n) => n.visible)
                .map((node) => {
                    return (
                        <TreeNode key={node.nodeId} {...node} onClick={handleNodeClick}>
                            {renderFactory(node)}
                        </TreeNode>
                    );
                })}
        </TreeViewContainer>
    );
}

This works fine in actual practice, and tests fine from the point of view of the component that renders the tree from a server-side call. This is probably where I should stop, but I have a lower-level test that broke once I refactored the tree to use useDeepCompareEffect:

test('rerenders the tree if the nodelist changes', () => {
    const nodeList: ITreeNode[] = [
        {
            nodeId: 'root',
            parentId: undefined,
            visible: true,
            open: true,
            hasChildren: true,
            depth: 1,
        },
    ];
    const { rerender } = render(<TreeView nodes={nodeList} onNodeSelected={jest.fn()} renderFactory={testRenderFactory} />);

    expect(screen.getAllByRole('treeitem').length).toBe(1);

    nodeList.push({
        nodeId: 'child',
        parentId: 'root',
        visible: true,
        depth: 2,
    });
    rerender(<TreeView nodes={nodeList} onNodeSelected={jest.fn()} renderFactory={testRenderFactory} />);
    
    expect(screen.getAllByRole('treeitem').length).toBe(2);
});

I'd like to know why this test is failing. Looking at the implementation of useDeepCompareEffect, it seems to me that it should see that change.

Debugging and console logging tell me that though the incoming nodelist changes, the effect isn't called (dispatch({ type: 'refresh', payload: nodes });). The assertion fails because only one treeitem is ever rendered in this test.



Solution 1:[1]

I took my own advice -- "Don't do that."

Here's a better test:

test('rerenders the tree if the nodelist changes', () => {
    function RefreshHarness() {
        const [nodes, setNodes] = useState<ITreeNode[]>([
            {
                nodeId: 'root',
                parentId: undefined,
                visible: true,
                open: true,
                hasChildren: true,
                depth: 1,
            },
        ]);

        const handleClick = () => {
            setNodes([
                ...nodes,
                {
                    nodeId: 'child',
                    parentId: 'root',
                    visible: true,
                    depth: 2,
                },
            ]);
        };

        return (
            <div>
                <Button onClick={handleClick}>Test Me</Button>
                <TreeView nodes={nodes} onNodeSelected={jest.fn()} renderFactory={testRenderFactory} />
            </div>
        );
    }

    render(<RefreshHarness />);

    expect(screen.getAllByRole('treeitem')).toHaveLength(1);

    userEvent.click(screen.getByRole('button', { name: /test me/i }));

    expect(screen.getAllByRole('treeitem')).toHaveLength(2);
});

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Stuart