traceVirtualizedList.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import {useLayoutEffect, useRef, useState} from 'react';
  2. import {requestAnimationTimeout} from 'sentry/utils/profiling/hooks/useVirtualizedTree/virtualizedTreeUtils';
  3. import type {TraceTree} from '../traceModels/traceTree';
  4. import type {TraceTreeNode} from '../traceModels/traceTreeNode';
  5. import type {TraceScheduler} from '../traceRenderers/traceScheduler';
  6. import {
  7. VirtualizedList,
  8. type VirtualizedViewManager,
  9. } from '../traceRenderers/virtualizedViewManager';
  10. export interface VirtualizedRow {
  11. index: number;
  12. item: TraceTreeNode<TraceTree.NodeValue>;
  13. key: number;
  14. style: React.CSSProperties;
  15. }
  16. interface UseVirtualizedListProps {
  17. container: HTMLElement | null;
  18. items: ReadonlyArray<TraceTreeNode<TraceTree.NodeValue>>;
  19. manager: VirtualizedViewManager;
  20. render: (item: VirtualizedRow) => React.ReactNode;
  21. scheduler: TraceScheduler;
  22. }
  23. interface UseVirtualizedListResult {
  24. list: VirtualizedList;
  25. rendered: React.ReactNode[];
  26. virtualized: VirtualizedRow[];
  27. }
  28. export const useVirtualizedList = (
  29. props: UseVirtualizedListProps
  30. ): UseVirtualizedListResult => {
  31. const list = useRef<VirtualizedList | null>();
  32. const scrollTopRef = useRef<number>(0);
  33. const scrollHeightRef = useRef<number>(0);
  34. const scrollContainerRef = useRef<HTMLElement | null>(null);
  35. const renderCache = useRef<Map<number, React.ReactNode>>();
  36. const styleCache = useRef<Map<number, React.CSSProperties>>();
  37. const resizeObserverRef = useRef<ResizeObserver | null>(null);
  38. if (!styleCache.current) {
  39. styleCache.current = new Map();
  40. }
  41. if (!renderCache.current) {
  42. renderCache.current = new Map();
  43. }
  44. const [items, setItems] = useState<{
  45. rendered: React.ReactNode[];
  46. virtualized: VirtualizedRow[];
  47. }>({rendered: [], virtualized: []});
  48. if (!list.current) {
  49. list.current = new VirtualizedList();
  50. props.manager.registerList(list.current);
  51. }
  52. const renderRef = useRef<(item: VirtualizedRow) => React.ReactNode>(props.render);
  53. renderRef.current = props.render;
  54. const itemsRef = useRef<ReadonlyArray<TraceTreeNode<TraceTree.NodeValue>>>(props.items);
  55. itemsRef.current = props.items;
  56. const managerRef = useRef<VirtualizedViewManager>(props.manager);
  57. managerRef.current = props.manager;
  58. useLayoutEffect(() => {
  59. if (!props.container) {
  60. return;
  61. }
  62. const scrollContainer = props.container.children[0] as HTMLElement | null;
  63. if (!scrollContainer) {
  64. throw new Error(
  65. 'Virtualized list container has to render a scroll container as its first child.'
  66. );
  67. }
  68. }, [props.container, props.items.length]);
  69. useLayoutEffect(() => {
  70. if (!props.container || !list.current) {
  71. return;
  72. }
  73. list.current.container = props.container;
  74. if (resizeObserverRef.current) {
  75. resizeObserverRef.current.disconnect();
  76. }
  77. const resizeObserver = new ResizeObserver(elements => {
  78. // We only care about changes to the height of the scroll container,
  79. // if it has not changed then do not update the scroll height.
  80. styleCache.current?.clear();
  81. renderCache.current?.clear();
  82. scrollHeightRef.current = elements[0].contentRect.height;
  83. if (list.current) {
  84. list.current.scrollHeight = scrollHeightRef.current;
  85. }
  86. maybeToggleScrollbar(
  87. elements[0].target as HTMLElement,
  88. scrollHeightRef.current,
  89. itemsRef.current.length * 24,
  90. managerRef.current
  91. );
  92. const recomputedItems = findRenderedItems({
  93. scrollTop: scrollTopRef.current,
  94. items: itemsRef.current,
  95. overscroll: 5,
  96. rowHeight: 24,
  97. scrollHeight: scrollHeightRef.current,
  98. styleCache: styleCache.current!,
  99. renderCache: renderCache.current!,
  100. render: renderRef.current,
  101. manager: managerRef.current,
  102. });
  103. setItems(recomputedItems);
  104. });
  105. resizeObserver.observe(props.container);
  106. resizeObserverRef.current = resizeObserver;
  107. }, [props.container]);
  108. const rafId = useRef<number | null>(null);
  109. const pointerEventsRaf = useRef<{id: number} | null>(null);
  110. useLayoutEffect(() => {
  111. if (!list.current || !props.container) {
  112. return undefined;
  113. }
  114. if (props.container && !scrollContainerRef.current) {
  115. scrollContainerRef.current = props.container.children[0] as HTMLElement | null;
  116. }
  117. props.container.style.height = '100%';
  118. props.container.style.overflow = 'auto';
  119. props.container.style.position = 'relative';
  120. props.container.style.willChange = 'transform';
  121. props.container.style.overscrollBehavior = 'none';
  122. scrollContainerRef.current!.style.overflow = 'hidden';
  123. scrollContainerRef.current!.style.position = 'relative';
  124. scrollContainerRef.current!.style.willChange = 'transform';
  125. scrollContainerRef.current!.style.height = `${props.items.length * 24}px`;
  126. props.scheduler.dispatch('initialize virtualized list');
  127. maybeToggleScrollbar(
  128. props.container,
  129. scrollHeightRef.current,
  130. props.items.length * 24,
  131. props.manager
  132. );
  133. const onScroll = event => {
  134. if (!list.current) {
  135. return;
  136. }
  137. if (rafId.current !== null) {
  138. window.cancelAnimationFrame(rafId.current);
  139. }
  140. managerRef.current.scrolling_source = 'list';
  141. managerRef.current.enqueueOnScrollEndOutOfBoundsCheck();
  142. rafId.current = window.requestAnimationFrame(() => {
  143. scrollTopRef.current = Math.max(0, event.target?.scrollTop ?? 0);
  144. const recomputedItems = findRenderedItems({
  145. scrollTop: scrollTopRef.current,
  146. items: props.items,
  147. overscroll: 5,
  148. rowHeight: 24,
  149. scrollHeight: scrollHeightRef.current,
  150. styleCache: styleCache.current!,
  151. renderCache: renderCache.current!,
  152. render: renderRef.current,
  153. manager: managerRef.current,
  154. });
  155. setItems(recomputedItems);
  156. });
  157. if (!pointerEventsRaf.current && scrollContainerRef.current) {
  158. scrollContainerRef.current.style.pointerEvents = 'none';
  159. }
  160. if (pointerEventsRaf.current) {
  161. window.cancelAnimationFrame(pointerEventsRaf.current.id);
  162. }
  163. pointerEventsRaf.current = requestAnimationTimeout(() => {
  164. styleCache.current?.clear();
  165. renderCache.current?.clear();
  166. managerRef.current.scrolling_source = null;
  167. const recomputedItems = findRenderedItems({
  168. scrollTop: scrollTopRef.current,
  169. items: props.items,
  170. overscroll: 5,
  171. rowHeight: 24,
  172. scrollHeight: scrollHeightRef.current,
  173. styleCache: styleCache.current!,
  174. renderCache: renderCache.current!,
  175. render: renderRef.current,
  176. manager: managerRef.current,
  177. });
  178. setItems(recomputedItems);
  179. if (list.current && scrollContainerRef.current) {
  180. scrollContainerRef.current.style.pointerEvents = 'auto';
  181. pointerEventsRaf.current = null;
  182. }
  183. }, 150);
  184. };
  185. props.container.addEventListener('scroll', onScroll, {passive: true});
  186. return () => {
  187. props.container?.removeEventListener('scroll', onScroll);
  188. };
  189. }, [props.container, props.items, props.items.length, props.manager, props.scheduler]);
  190. useLayoutEffect(() => {
  191. if (!list.current || !styleCache.current || !renderCache.current) {
  192. return;
  193. }
  194. styleCache.current.clear();
  195. renderCache.current.clear();
  196. const recomputedItems = findRenderedItems({
  197. scrollTop: scrollTopRef.current,
  198. items: props.items,
  199. overscroll: 5,
  200. rowHeight: 24,
  201. scrollHeight: scrollHeightRef.current,
  202. styleCache: styleCache.current!,
  203. renderCache: renderCache.current,
  204. render: renderRef.current,
  205. manager: managerRef.current,
  206. });
  207. setItems(recomputedItems);
  208. }, [props.items, props.items.length, props.render]);
  209. return {
  210. virtualized: items.virtualized,
  211. rendered: items.rendered,
  212. list: list.current!,
  213. };
  214. };
  215. function findRenderedItems({
  216. items,
  217. overscroll,
  218. rowHeight,
  219. scrollHeight,
  220. scrollTop,
  221. styleCache,
  222. renderCache,
  223. render,
  224. manager,
  225. }: {
  226. items: ReadonlyArray<TraceTreeNode<TraceTree.NodeValue>>;
  227. manager: VirtualizedViewManager;
  228. overscroll: number;
  229. render: (arg: VirtualizedRow) => React.ReactNode;
  230. renderCache: Map<number, React.ReactNode>;
  231. rowHeight: number;
  232. scrollHeight: number;
  233. scrollTop: number;
  234. styleCache: Map<number, React.CSSProperties>;
  235. }): {rendered: React.ReactNode[]; virtualized: VirtualizedRow[]} {
  236. // This is overscroll height for single direction, when computing the total,
  237. // we need to multiply this by 2 because we overscroll in both directions.
  238. const OVERSCROLL_HEIGHT = overscroll * rowHeight;
  239. const virtualized: VirtualizedRow[] = [];
  240. const rendered: React.ReactNode[] = [];
  241. // Clamp viewport to scrollHeight bounds [0, length * rowHeight] because some browsers may fire
  242. // scrollTop with negative values when the user scrolls up past the top of the list (overscroll behavior)
  243. const viewport = {
  244. top: Math.max(scrollTop - OVERSCROLL_HEIGHT, 0),
  245. bottom: Math.min(
  246. scrollTop + scrollHeight + OVERSCROLL_HEIGHT,
  247. items.length * rowHeight
  248. ),
  249. };
  250. // Points to the position inside the visible array
  251. let visibleItemIndex = 0;
  252. // Points to the currently iterated item
  253. let indexPointer = findOptimisticStartIndex({
  254. items,
  255. viewport,
  256. scrollTop,
  257. rowHeight,
  258. overscroll,
  259. });
  260. manager.start_virtualized_index = indexPointer;
  261. // Max number of visible items in our list
  262. const MAX_VISIBLE_ITEMS = Math.ceil((scrollHeight + OVERSCROLL_HEIGHT * 2) / rowHeight);
  263. const ALL_ITEMS = items.length;
  264. // While number of visible items is less than max visible items, and we haven't reached the end of the list
  265. while (visibleItemIndex < MAX_VISIBLE_ITEMS && indexPointer < ALL_ITEMS) {
  266. const elementTop = indexPointer * rowHeight;
  267. const elementBottom = elementTop + rowHeight;
  268. // An element is inside a viewport if the top of the element is below the top of the viewport
  269. // and the bottom of the element is above the bottom of the viewport
  270. if (elementTop >= viewport.top && elementBottom <= viewport.bottom) {
  271. let style = styleCache.get(indexPointer);
  272. if (!style) {
  273. style = {position: 'absolute', transform: `translate(0px, ${elementTop}px)`};
  274. styleCache.set(indexPointer, style);
  275. }
  276. const virtualizedRow: VirtualizedRow = {
  277. key: indexPointer,
  278. style,
  279. index: indexPointer,
  280. item: items[indexPointer],
  281. };
  282. virtualized[visibleItemIndex] = virtualizedRow;
  283. const renderedRow = renderCache.get(indexPointer) || render(virtualizedRow);
  284. rendered[visibleItemIndex] = renderedRow;
  285. renderCache.set(indexPointer, renderedRow);
  286. visibleItemIndex++;
  287. }
  288. indexPointer++;
  289. }
  290. return {rendered, virtualized};
  291. }
  292. export function findOptimisticStartIndex({
  293. items,
  294. overscroll,
  295. rowHeight,
  296. scrollTop,
  297. viewport,
  298. }: {
  299. items: ReadonlyArray<TraceTreeNode<TraceTree.NodeValue>>;
  300. overscroll: number;
  301. rowHeight: number;
  302. scrollTop: number;
  303. viewport: {bottom: number; top: number};
  304. }): number {
  305. if (!items.length || viewport.top === 0) {
  306. return 0;
  307. }
  308. return Math.max(Math.floor(scrollTop / rowHeight) - overscroll, 0);
  309. }
  310. function maybeToggleScrollbar(
  311. container: HTMLElement,
  312. containerHeight: number,
  313. scrollHeight: number,
  314. manager: VirtualizedViewManager
  315. ) {
  316. if (scrollHeight > containerHeight) {
  317. container.style.overflowY = 'scroll';
  318. container.style.scrollbarGutter = 'stable';
  319. manager.onScrollbarWidthChange(container.offsetWidth - container.clientWidth);
  320. } else {
  321. container.style.overflowY = 'auto';
  322. container.style.scrollbarGutter = 'auto';
  323. manager.onScrollbarWidthChange(0);
  324. }
  325. }