Skip to content

Commit 75f9749

Browse files
committed
ref(feedback): Replace react-virtualized with
`@tanstack/react-virtual` in Feedback
1 parent e9dfe8a commit 75f9749

File tree

4 files changed

+268
-129
lines changed

4 files changed

+268
-129
lines changed

static/app/components/feedback/list/feedbackList.tsx

Lines changed: 50 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,171 +1,95 @@
1-
import {Fragment, useMemo, useRef} from 'react';
2-
import type {ListRowProps} from 'react-virtualized';
3-
import {
4-
AutoSizer,
5-
CellMeasurer,
6-
InfiniteLoader,
7-
List as ReactVirtualizedList,
8-
} from 'react-virtualized';
1+
import {Fragment, useMemo} from 'react';
92
import styled from '@emotion/styled';
103

114
import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg';
125

6+
import {Flex} from 'sentry/components/core/layout';
137
import {Tooltip} from 'sentry/components/core/tooltip';
148
import ErrorBoundary from 'sentry/components/errorBoundary';
159
import FeedbackListHeader from 'sentry/components/feedback/list/feedbackListHeader';
1610
import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem';
1711
import useFeedbackQueryKeys from 'sentry/components/feedback/useFeedbackQueryKeys';
12+
import InfiniteListItems from 'sentry/components/infiniteList/infiniteListItems';
13+
import InfiniteListState from 'sentry/components/infiniteList/infiniteListState';
1814
import LoadingIndicator from 'sentry/components/loadingIndicator';
1915
import {t} from 'sentry/locale';
2016
import {space} from 'sentry/styles/space';
21-
import useFetchInfiniteListData from 'sentry/utils/api/useFetchInfiniteListData';
17+
import uniqueBy from 'sentry/utils/array/uniqBy';
2218
import type {FeedbackIssueListItem} from 'sentry/utils/feedback/types';
2319
import {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
24-
import useVirtualizedList from 'sentry/views/replays/detail/useVirtualizedList';
20+
import {useInfiniteApiQuery} from 'sentry/utils/queryClient';
2521

26-
// Ensure this object is created once as it is an input to
27-
// `useVirtualizedList`'s memoization
28-
const cellMeasurer = {
29-
fixedWidth: true,
30-
minHeight: 24,
31-
};
32-
33-
function NoFeedback({title, subtitle}: {subtitle: string; title: string}) {
22+
function NoFeedback() {
3423
return (
35-
<Wrapper>
36-
<img src={waitingForEventImg} alt="No feedback found spot illustration" />
37-
<EmptyMessage>{title}</EmptyMessage>
38-
<p>{subtitle}</p>
39-
</Wrapper>
24+
<NoFeedbackWrapper>
25+
<img src={waitingForEventImg} alt={t('A person waiting for a phone to ring')} />
26+
<NoFeedbackMessage>{t('Inbox Zero')}</NoFeedbackMessage>
27+
<p>{t('You have two options: take a nap or be productive.')}</p>
28+
</NoFeedbackWrapper>
4029
);
4130
}
4231

4332
export default function FeedbackList() {
4433
const {listQueryKey} = useFeedbackQueryKeys();
45-
const {
46-
isFetchingNextPage,
47-
isFetchingPreviousPage,
48-
isLoading, // If anything is loaded yet
49-
getRow,
50-
isRowLoaded,
51-
issues,
52-
loadMoreRows,
53-
hits,
54-
} = useFetchInfiniteListData<FeedbackIssueListItem>({
34+
const queryResult = useInfiniteApiQuery<FeedbackIssueListItem[]>({
5535
queryKey: listQueryKey ?? ['infinite', ''],
56-
uniqueField: 'id',
5736
enabled: Boolean(listQueryKey),
5837
});
5938

39+
const issues = useMemo(
40+
() => uniqueBy(queryResult.data?.pages.flatMap(([pageData]) => pageData) ?? [], 'id'),
41+
[queryResult.data?.pages]
42+
);
6043
const checkboxState = useListItemCheckboxContext({
61-
hits,
44+
hits: issues.length,
6245
knownIds: issues.map(issue => issue.id),
6346
queryKey: listQueryKey,
6447
});
6548

66-
const listRef = useRef<ReactVirtualizedList>(null);
67-
68-
const deps = useMemo(() => [isLoading, issues.length], [isLoading, issues.length]);
69-
const {cache, updateList} = useVirtualizedList({
70-
cellMeasurer,
71-
ref: listRef,
72-
deps,
73-
});
74-
75-
const renderRow = ({index, key, style, parent}: ListRowProps) => {
76-
const item = getRow({index});
77-
if (!item) {
78-
return null;
79-
}
80-
81-
return (
82-
<ErrorBoundary mini key={key}>
83-
<CellMeasurer cache={cache} columnIndex={0} parent={parent} rowIndex={index}>
84-
<FeedbackListItem
85-
feedbackItem={item}
86-
isSelected={checkboxState.isSelected(item.id)}
87-
onSelect={() => {
88-
checkboxState.toggleSelected(item.id);
89-
}}
90-
style={style}
91-
/>
92-
</CellMeasurer>
93-
</ErrorBoundary>
94-
);
95-
};
96-
9749
return (
9850
<Fragment>
9951
<FeedbackListHeader {...checkboxState} />
100-
<FeedbackListItems>
101-
<InfiniteLoader
102-
isRowLoaded={isRowLoaded}
103-
loadMoreRows={loadMoreRows}
104-
rowCount={hits}
52+
<Flex direction="column" style={{flexGrow: 1}}>
53+
<InfiniteListState
54+
queryResult={queryResult}
55+
backgroundUpdatingMessage={() => null}
56+
loadingMessage={() => <LoadingIndicator />}
10557
>
106-
{({onRowsRendered, registerChild}) => (
107-
<AutoSizer onResize={updateList}>
108-
{({width, height}) => (
109-
<ReactVirtualizedList
110-
deferredMeasurementCache={cache}
111-
height={height}
112-
noRowsRenderer={() =>
113-
isLoading ? (
114-
<LoadingIndicator />
115-
) : (
116-
<NoFeedback
117-
title={t('Inbox Zero')}
118-
subtitle={t('You have two options: take a nap or be productive.')}
119-
/>
120-
)
121-
}
122-
onRowsRendered={onRowsRendered}
123-
overscanRowCount={5}
124-
ref={e => {
125-
listRef.current = e;
126-
registerChild(e);
58+
<InfiniteListItems<FeedbackIssueListItem>
59+
estimateSize={() => 24}
60+
queryResult={queryResult}
61+
itemRenderer={({item}) => (
62+
<ErrorBoundary mini>
63+
<FeedbackListItem
64+
feedbackItem={item}
65+
isSelected={checkboxState.isSelected(item.id)}
66+
onSelect={() => {
67+
checkboxState.toggleSelected(item.id);
12768
}}
128-
rowCount={issues.length}
129-
rowHeight={cache.rowHeight}
130-
rowRenderer={renderRow}
131-
width={width}
13269
/>
133-
)}
134-
</AutoSizer>
135-
)}
136-
</InfiniteLoader>
137-
<FloatingContainer style={{top: '2px'}}>
138-
{isFetchingPreviousPage ? (
139-
<Tooltip title={t('Loading more feedback...')}>
140-
<LoadingIndicator mini />
141-
</Tooltip>
142-
) : null}
143-
</FloatingContainer>
144-
<FloatingContainer style={{bottom: '2px'}}>
145-
{isFetchingNextPage ? (
146-
<Tooltip title={t('Loading more feedback...')}>
147-
<LoadingIndicator mini />
148-
</Tooltip>
149-
) : null}
150-
</FloatingContainer>
151-
</FeedbackListItems>
70+
</ErrorBoundary>
71+
)}
72+
emptyMessage={() => <NoFeedback />}
73+
loadingMoreMessage={() => (
74+
<LoadingMoreContainer>
75+
<Tooltip title={t('Loading more feedback...')}>
76+
<LoadingIndicator mini />
77+
</Tooltip>
78+
</LoadingMoreContainer>
79+
)}
80+
loadingCompleteMessage={() => null}
81+
/>
82+
</InfiniteListState>
83+
</Flex>
15284
</Fragment>
15385
);
15486
}
15587

156-
const FeedbackListItems = styled('div')`
157-
display: grid;
158-
flex-grow: 1;
159-
min-height: 300px;
160-
`;
161-
162-
const FloatingContainer = styled('div')`
163-
position: absolute;
88+
const LoadingMoreContainer = styled('div')`
16489
justify-self: center;
16590
`;
16691

167-
const Wrapper = styled('div')`
168-
display: flex;
92+
const NoFeedbackWrapper = styled('div')`
16993
padding: ${space(4)} ${space(4)};
17094
flex-direction: column;
17195
align-items: center;
@@ -175,12 +99,9 @@ const Wrapper = styled('div')`
17599
@media (max-width: ${p => p.theme.breakpoints.sm}) {
176100
font-size: ${p => p.theme.fontSize.md};
177101
}
178-
position: relative;
179-
top: 50%;
180-
transform: translateY(-50%);
181102
`;
182103

183-
const EmptyMessage = styled('div')`
104+
const NoFeedbackMessage = styled('div')`
184105
font-weight: ${p => p.theme.fontWeight.bold};
185106
color: ${p => p.theme.gray400};
186107
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {useEffect, useRef} from 'react';
2+
import styled from '@emotion/styled';
3+
import type {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query';
4+
import {useVirtualizer} from '@tanstack/react-virtual';
5+
6+
import type {ApiResult} from 'sentry/api';
7+
import LoadingIndicator from 'sentry/components/loadingIndicator';
8+
import {t} from 'sentry/locale';
9+
10+
type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
11+
12+
interface Props<Data> {
13+
itemRenderer: ({index, item}: {index: number; item: Data}) => React.ReactNode;
14+
queryResult: Overwrite<
15+
Pick<
16+
UseInfiniteQueryResult<InfiniteData<ApiResult<Data[]>>, Error>,
17+
'data' | 'hasNextPage' | 'isFetchingNextPage' | 'fetchNextPage'
18+
>,
19+
{fetchNextPage: () => Promise<unknown>}
20+
>;
21+
emptyMessage?: () => React.ReactNode;
22+
estimateSize?: () => number;
23+
loadingCompleteMessage?: () => React.ReactNode;
24+
loadingMoreMessage?: () => React.ReactNode;
25+
overscan?: number;
26+
}
27+
28+
export default function InfiniteListItems<Data>({
29+
estimateSize,
30+
itemRenderer,
31+
emptyMessage = EmptyMessage,
32+
loadingCompleteMessage = LoadingCompleteMessage,
33+
loadingMoreMessage = LoadingMoreMessage,
34+
overscan,
35+
queryResult,
36+
}: Props<Data>) {
37+
const {data, hasNextPage, isFetchingNextPage, fetchNextPage} = queryResult;
38+
const loadedRows = data ? data.pages.flatMap(d => d[0]) : [];
39+
const parentRef = useRef<HTMLDivElement>(null);
40+
41+
const rowVirtualizer = useVirtualizer({
42+
count: hasNextPage ? loadedRows.length + 1 : loadedRows.length,
43+
getScrollElement: () => parentRef.current,
44+
estimateSize: estimateSize ?? (() => 100),
45+
overscan: overscan ?? 5,
46+
});
47+
const items = rowVirtualizer.getVirtualItems();
48+
49+
useEffect(() => {
50+
const lastItem = items.at(-1);
51+
if (!lastItem) {
52+
return;
53+
}
54+
55+
if (lastItem.index >= loadedRows.length - 1 && hasNextPage && !isFetchingNextPage) {
56+
fetchNextPage();
57+
}
58+
}, [hasNextPage, fetchNextPage, loadedRows.length, isFetchingNextPage, items]);
59+
60+
return (
61+
<FlexOverscroll ref={parentRef}>
62+
<FlexListContainer style={{height: rowVirtualizer.getTotalSize()}}>
63+
<PositionedList style={{transform: `translateY(${items[0]?.start ?? 0}px)`}}>
64+
{items.length ? null : emptyMessage()}
65+
{items.map(virtualRow => {
66+
const isLoaderRow = virtualRow.index > loadedRows.length - 1;
67+
const item = loadedRows.at(virtualRow.index);
68+
69+
return (
70+
<li
71+
data-index={virtualRow.index}
72+
key={virtualRow.index}
73+
ref={rowVirtualizer.measureElement}
74+
>
75+
{isLoaderRow
76+
? hasNextPage
77+
? loadingMoreMessage()
78+
: loadingCompleteMessage()
79+
: item && itemRenderer({index: virtualRow.index, item})}
80+
</li>
81+
);
82+
})}
83+
</PositionedList>
84+
</FlexListContainer>
85+
</FlexOverscroll>
86+
);
87+
}
88+
89+
function EmptyMessage() {
90+
return <p>{t('No items to show')}</p>;
91+
}
92+
93+
function LoadingMoreMessage() {
94+
return (
95+
<Footer title={t('Loading more items...')}>
96+
<LoadingIndicator size={20} />
97+
</Footer>
98+
);
99+
}
100+
101+
function LoadingCompleteMessage() {
102+
return <p>{t('Nothing more to load')}</p>;
103+
}
104+
105+
const FlexOverscroll = styled('div')`
106+
display: flex;
107+
width: 100%;
108+
height: 100%;
109+
flex-direction: column;
110+
overflow: auto;
111+
overscroll-behavior: contain;
112+
contain: strict;
113+
`;
114+
115+
const FlexListContainer = styled('div')`
116+
position: absolute;
117+
display: flex;
118+
width: 100%;
119+
flex-direction: column;
120+
`;
121+
122+
const PositionedList = styled('ul')`
123+
position: absolute;
124+
left: 0;
125+
top: 0;
126+
width: 100%;
127+
128+
margin: 0;
129+
padding: 0;
130+
list-style: none;
131+
& > li {
132+
margin: 0;
133+
padding: 0;
134+
}
135+
`;
136+
137+
const Footer = styled('footer')`
138+
position: absolute;
139+
bottom: 0;
140+
z-index: ${p => p.theme.zIndex.initial};
141+
display: flex;
142+
width: 100%;
143+
flex-grow: 1;
144+
align-items: center;
145+
justify-content: center;
146+
`;

0 commit comments

Comments
 (0)