traceVirtualizedList.tsx 12 KB

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