Skip to content

Commit b6c6c3a

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

File tree

4 files changed

+274
-123
lines changed

4 files changed

+274
-123
lines changed

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

Lines changed: 50 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
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';
@@ -15,157 +8,94 @@ import ErrorBoundary from 'sentry/components/errorBoundary';
158
import FeedbackListHeader from 'sentry/components/feedback/list/feedbackListHeader';
169
import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem';
1710
import useFeedbackQueryKeys from 'sentry/components/feedback/useFeedbackQueryKeys';
11+
import InfiniteListItems from 'sentry/components/infiniteList/infiniteListItems';
12+
import InfiniteListState from 'sentry/components/infiniteList/infiniteListState';
1813
import LoadingIndicator from 'sentry/components/loadingIndicator';
1914
import {t} from 'sentry/locale';
2015
import {space} from 'sentry/styles/space';
21-
import useFetchInfiniteListData from 'sentry/utils/api/useFetchInfiniteListData';
16+
import uniqueBy from 'sentry/utils/array/uniqBy';
2217
import type {FeedbackIssueListItem} from 'sentry/utils/feedback/types';
2318
import {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
24-
import useVirtualizedList from 'sentry/views/replays/detail/useVirtualizedList';
19+
import {useInfiniteApiQuery} from 'sentry/utils/queryClient';
2520

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}) {
21+
function NoFeedback() {
3422
return (
35-
<Wrapper>
36-
<img src={waitingForEventImg} alt="No feedback found spot illustration" />
37-
<EmptyMessage>{title}</EmptyMessage>
38-
<p>{subtitle}</p>
39-
</Wrapper>
23+
<NoFeedbackWrapper>
24+
<img src={waitingForEventImg} alt={t('A person waiting for a phone to ring')} />
25+
<NoFeedbackMessage>{t('Inbox Zero')}</NoFeedbackMessage>
26+
<p>{t('You have two options: take a nap or be productive.')}</p>
27+
</NoFeedbackWrapper>
4028
);
4129
}
4230

4331
export default function FeedbackList() {
4432
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>({
33+
const queryResult = useInfiniteApiQuery<FeedbackIssueListItem[]>({
5534
queryKey: listQueryKey ?? ['infinite', ''],
56-
uniqueField: 'id',
5735
enabled: Boolean(listQueryKey),
5836
});
5937

38+
const issues = useMemo(
39+
() => uniqueBy(queryResult.data?.pages.flatMap(([pageData]) => pageData) ?? [], 'id'),
40+
[queryResult.data?.pages]
41+
);
6042
const checkboxState = useListItemCheckboxContext({
61-
hits,
43+
hits: issues.length,
6244
knownIds: issues.map(issue => issue.id),
6345
queryKey: listQueryKey,
6446
});
6547

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-
9748
return (
9849
<Fragment>
9950
<FeedbackListHeader {...checkboxState} />
10051
<FeedbackListItems>
101-
<InfiniteLoader
102-
isRowLoaded={isRowLoaded}
103-
loadMoreRows={loadMoreRows}
104-
rowCount={hits}
52+
<InfiniteListState
53+
queryResult={queryResult}
54+
backgroundUpdatingMessage={() => null}
55+
loadingMessage={() => <LoadingIndicator />}
10556
>
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);
57+
<InfiniteListItems<FeedbackIssueListItem>
58+
estimateSize={() => 24}
59+
queryResult={queryResult}
60+
itemRenderer={({item}) => (
61+
<ErrorBoundary mini>
62+
<FeedbackListItem
63+
feedbackItem={item}
64+
isSelected={checkboxState.isSelected(item.id)}
65+
onSelect={() => {
66+
checkboxState.toggleSelected(item.id);
12767
}}
128-
rowCount={issues.length}
129-
rowHeight={cache.rowHeight}
130-
rowRenderer={renderRow}
131-
width={width}
13268
/>
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>
69+
</ErrorBoundary>
70+
)}
71+
emptyMessage={() => <NoFeedback />}
72+
loadingMoreMessage={() => (
73+
<Centered>
74+
<Tooltip title={t('Loading more feedback...')}>
75+
<LoadingIndicator mini />
76+
</Tooltip>
77+
</Centered>
78+
)}
79+
loadingCompleteMessage={() => null}
80+
/>
81+
</InfiniteListState>
15182
</FeedbackListItems>
15283
</Fragment>
15384
);
15485
}
15586

15687
const FeedbackListItems = styled('div')`
157-
display: grid;
88+
display: flex;
89+
flex-direction: column;
15890
flex-grow: 1;
159-
min-height: 300px;
91+
padding-bottom: ${space(0.5)};
16092
`;
16193

162-
const FloatingContainer = styled('div')`
163-
position: absolute;
94+
const Centered = styled('div')`
16495
justify-self: center;
16596
`;
16697

167-
const Wrapper = styled('div')`
168-
display: flex;
98+
const NoFeedbackWrapper = styled('div')`
16999
padding: ${space(4)} ${space(4)};
170100
flex-direction: column;
171101
align-items: center;
@@ -175,12 +105,9 @@ const Wrapper = styled('div')`
175105
@media (max-width: ${p => p.theme.breakpoints.sm}) {
176106
font-size: ${p => p.theme.fontSize.md};
177107
}
178-
position: relative;
179-
top: 50%;
180-
transform: translateY(-50%);
181108
`;
182109

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

0 commit comments

Comments
 (0)