[MM-64618] ResizeObserver callback fires after component unmount during channel switching (#32668)

Этот коммит содержится в:
M-ZubairAhmed
2025-07-02 22:30:17 +05:30
коммит произвёл GitHub
родитель d2188ce1dd
Коммит 0d9c4810f4
5 изменённых файлов: 114 добавлений и 12 удалений

Просмотреть файл

@@ -188,11 +188,11 @@
"build": "cross-env NODE_ENV=production webpack",
"run": "webpack --progress --watch",
"dev-server": "webpack serve --mode development",
"test": "cross-env TZ=Etc/UTC jest",
"test:watch": "cross-env TZ=Etc/UTC jest --watch",
"test:updatesnapshot": "cross-env TZ=Etc/UTC jest --updateSnapshot",
"test:debug": "cross-env TZ=Etc/UTC jest --forceExit --detectOpenHandles --verbose",
"test-ci": "cross-env TZ=Etc/UTC jest --ci --maxWorkers=100% --coverage",
"test": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest",
"test:watch": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --watch",
"test:updatesnapshot": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --updateSnapshot",
"test:debug": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --forceExit --detectOpenHandles --verbose",
"test-ci": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --ci --maxWorkers=100% --coverage",
"clean": "rm -rf dist node_modules .eslintcache .stylelintcache tsconfig.tsbuildinfo",
"stats": "cross-env NODE_ENV=production webpack --profile --json > webpack_stats.json",
"mmjstool": "mmjstool",

Просмотреть файл

@@ -7,7 +7,7 @@
import memoizeOne from 'memoize-one';
import {createElement, PureComponent} from 'react';
import ListItem from './item_row_shared';
import ListItem from './list_item';
const atBottomMargin = 10;

Просмотреть файл

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, screen} from '@testing-library/react';
import React from 'react';
jest.mock('./list_item_size_observer', () => {
const mockObserve = jest.fn(() => jest.fn());
const mockUnobserve = jest.fn();
return {
ListItemSizeObserver: {
getInstance: jest.fn(() => ({
observe: mockObserve,
unobserve: mockUnobserve,
})),
},
};
});
jest.mock('lodash/debounce', () => {
return jest.fn((fn) => {
const debouncedFn = (...args: any[]) => fn(...args);
debouncedFn.cancel = jest.fn();
debouncedFn.flush = jest.fn();
return debouncedFn;
});
});
import ListItem from './list_item';
describe('ListItem', () => {
const defaultProps = {
item: <div data-testid='test-item'>{'Test Item Content'}</div>,
itemId: 'test-item-1',
index: 0,
height: 100,
width: 300,
onHeightChange: jest.fn(),
onUnmount: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
test('renders the item content correctly', () => {
render(<ListItem {...defaultProps}/>);
expect(screen.getByTestId('test-item')).toBeInTheDocument();
expect(screen.getByText('Test Item Content')).toBeInTheDocument();
});
test('applies correct attributes to the wrapper div', () => {
render(<ListItem {...defaultProps}/>);
const wrapper = screen.getByRole('listitem');
expect(wrapper).toHaveClass('item_measurer');
expect(wrapper).toHaveAttribute('role', 'listitem');
});
test('calls onHeightChange on mount with initial height', () => {
const mockOnHeightChange = jest.fn();
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
value: 120,
});
render(
<ListItem
{...defaultProps}
onHeightChange={mockOnHeightChange}
/>,
);
expect(mockOnHeightChange).toHaveBeenCalledWith('test-item-1', 120, false);
});
test('handles zero offsetHeight gracefully', () => {
const mockOnHeightChange = jest.fn();
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
value: 0,
});
render(
<ListItem
{...defaultProps}
onHeightChange={mockOnHeightChange}
/>,
);
expect(mockOnHeightChange).toHaveBeenCalledWith('test-item-1', 0, false);
});
});

Просмотреть файл

@@ -5,7 +5,7 @@ import debounce from 'lodash/debounce';
import type {ReactNode} from 'react';
import React, {memo, useEffect, useRef} from 'react';
import {ListItemSizeObserver} from './item_row_size_observer';
import {ListItemSizeObserver} from './list_item_size_observer';
const RESIZE_DEBOUNCE_TIME = 200; // in ms
@@ -50,9 +50,15 @@ const ListItem = (props: Props) => {
// This effects adds the observer which calls height change callback debounced
useEffect(() => {
const debouncedOnHeightChange = debounce((changedHeight: number) => {
// Check if component is still mounted as it may have been
// unmounted by the time the debounced function is called
if (!rowRef.current) {
return;
}
// If width of container has changed then scroll bar position will be out of sync
// so we need to force a scroll correction
const forceScrollCorrection = rowRef.current?.offsetWidth !== widthRef.current;
const forceScrollCorrection = rowRef.current.offsetWidth !== widthRef.current;
heightRef.current = changedHeight;
@@ -76,11 +82,10 @@ const ListItem = (props: Props) => {
cleanupSizeObserver = listItemSizeObserver.observe(props.itemId, rowRef.current, itemRowSizeObserverCallback);
}
// We remove the observer here from a row
return () => {
if (cleanupSizeObserver) {
cleanupSizeObserver();
}
// We remove the observer here from a row
cleanupSizeObserver?.();
debouncedOnHeightChange?.cancel();
props.onUnmount(props.itemId, indexRef.current);
};
}, [props.itemId]);