Browse Source

ref(trace) use event emitter to schedule events (#73284)

Split the rendering logic to react to events emitted by the scheduler
(very similar to how profiling works). This will allow us to add
independent components that can react to view changes and rerender their
state however they need to and avoid react lifecycle updates where they
are not needed.

By doing this we also fix a race condition where we failed to scroll to
the row in the list (because we can await virtualized list
initialization on initial render)

This prepares the UI work for minimap as the timeline indicators can
subscribe to updates and are decoupled from the main span view
Jonas 8 months ago
parent
commit
ade63b942a

+ 110 - 44
static/app/views/performance/newTraceDetails/index.tsx

@@ -44,6 +44,12 @@ import useOrganization from 'sentry/utils/useOrganization';
 import {useParams} from 'sentry/utils/useParams';
 import useProjects from 'sentry/utils/useProjects';
 import {traceAnalytics} from 'sentry/views/performance/newTraceDetails/traceAnalytics';
+import {
+  TraceEventPriority,
+  type TraceEvents,
+  TraceScheduler,
+} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceScheduler';
+import {TraceView as TraceViewModel} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceView';
 import {
   type ViewManagerScrollAnchor,
   VirtualizedViewManager,
@@ -263,6 +269,8 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
   const traceState = useTraceState();
   const traceDispatch = useTraceStateDispatch();
   const traceStateEmitter = useTraceStateEmitter();
+  const traceScheduler = useMemo(() => new TraceScheduler(), []);
+  const traceView = useMemo(() => new TraceViewModel(), []);
 
   const forceRerender = useCallback(() => {
     flushSync(rerender);
@@ -348,14 +356,62 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
 
   // Initialize the view manager right after the state reducer
   const viewManager = useMemo(() => {
-    return new VirtualizedViewManager({
-      list: {width: traceState.preferences.list.width},
-      span_list: {width: 1 - traceState.preferences.list.width},
-    });
+    return new VirtualizedViewManager(
+      {
+        list: {width: traceState.preferences.list.width},
+        span_list: {width: 1 - traceState.preferences.list.width},
+      },
+      traceScheduler,
+      traceView
+    );
     // We only care about initial state when we initialize the view manager
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
+  useLayoutEffect(() => {
+    const onTraceViewChange: TraceEvents['set trace view'] = view => {
+      traceView.setTraceView(view);
+      viewManager.enqueueFOVQueryParamSync(traceView);
+    };
+
+    const onPhysicalSpaceChange: TraceEvents['set container physical space'] =
+      container => {
+        traceView.setTracePhysicalSpace(container, [
+          0,
+          0,
+          container[2] * viewManager.columns.span_list.width,
+          container[3],
+        ]);
+      };
+
+    const onTraceSpaceChange: TraceEvents['initialize trace space'] = view => {
+      traceView.setTraceSpace(view);
+    };
+
+    // These handlers have high priority because they are responsible for
+    // updating the view coordinates. If we update them first, then any components downstream
+    // that rely on the view coordinates will be in sync with the view.
+    traceScheduler.on('set trace view', onTraceViewChange, TraceEventPriority.HIGH);
+    traceScheduler.on('set trace space', onTraceSpaceChange, TraceEventPriority.HIGH);
+    traceScheduler.on(
+      'set container physical space',
+      onPhysicalSpaceChange,
+      TraceEventPriority.HIGH
+    );
+    traceScheduler.on(
+      'initialize trace space',
+      onTraceSpaceChange,
+      TraceEventPriority.HIGH
+    );
+
+    return () => {
+      traceScheduler.off('set trace view', onTraceViewChange);
+      traceScheduler.off('set trace space', onTraceSpaceChange);
+      traceScheduler.off('set container physical space', onPhysicalSpaceChange);
+      traceScheduler.off('initialize trace space', onTraceSpaceChange);
+    };
+  }, [traceScheduler, traceView, viewManager]);
+
   // Initialize the tabs reducer when the tree initializes
   useLayoutEffect(() => {
     return traceDispatch({
@@ -578,24 +634,10 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
 
       // Always scroll to the row vertically
       viewManager.scrollToRow(index, anchor);
-      previouslyScrolledToNodeRef.current = node;
-
-      // If the row had not yet been measured, then enqueue a listener for when
-      // the row is rendered and measured. This ensures that horizontal scroll
-      // accurately narrows zooms to the start of the node as the new width will be updated
-      if (!viewManager.row_measurer.cache.has(node)) {
-        viewManager.row_measurer.once('row measure end', () => {
-          if (!viewManager.isOutsideOfViewOnKeyDown(node)) {
-            return;
-          }
-          viewManager.scrollRowIntoViewHorizontally(node, 0, 48, 'measured');
-        });
-      } else {
-        if (!viewManager.isOutsideOfViewOnKeyDown(node)) {
-          return;
-        }
+      if (viewManager.isOutsideOfView(node)) {
         viewManager.scrollRowIntoViewHorizontally(node, 0, 48, 'measured');
       }
+      previouslyScrolledToNodeRef.current = node;
     },
     [viewManager]
   );
@@ -706,23 +748,40 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
       }
 
       if (nodeToScrollTo !== null && indexOfNodeToScrollTo !== null) {
-        viewManager.scrollToRow(indexOfNodeToScrollTo, 'center');
-
-        // At load time, we want to scroll the row into view, but we need to ensure
-        // that the row had been measured first, else we can exceed the bounds of the container.
-        scrollRowIntoView(nodeToScrollTo, indexOfNodeToScrollTo, 'center');
+        // At load time, we want to scroll the row into view, but we need to wait for the view
+        // to initialize before we can do that. We listen for the 'initialize virtualized list' and scroll
+        // to the row in the view.
+        traceScheduler.once('initialize virtualized list', () => {
+          function onTargetRowMeasure() {
+            if (!nodeToScrollTo || !viewManager.row_measurer.cache.has(nodeToScrollTo)) {
+              return;
+            }
+            viewManager.row_measurer.off('row measure end', onTargetRowMeasure);
+            if (viewManager.isOutsideOfView(nodeToScrollTo)) {
+              viewManager.scrollRowIntoViewHorizontally(
+                nodeToScrollTo!,
+                0,
+                48,
+                'measured'
+              );
+            }
+          }
+          viewManager.scrollToRow(indexOfNodeToScrollTo, 'center');
+          viewManager.row_measurer.on('row measure end', onTargetRowMeasure);
+          previouslyScrolledToNodeRef.current = nodeToScrollTo;
 
-        setRowAsFocused(
-          nodeToScrollTo,
-          null,
-          traceStateRef.current.search.resultsLookup,
-          indexOfNodeToScrollTo
-        );
-        traceDispatch({
-          type: 'set roving index',
-          node: nodeToScrollTo,
-          index: indexOfNodeToScrollTo,
-          action_source: 'load',
+          setRowAsFocused(
+            nodeToScrollTo,
+            null,
+            traceStateRef.current.search.resultsLookup,
+            indexOfNodeToScrollTo
+          );
+          traceDispatch({
+            type: 'set roving index',
+            node: nodeToScrollTo,
+            index: indexOfNodeToScrollTo,
+            action_source: 'load',
+          });
         });
       }
 
@@ -730,7 +789,7 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
         onTraceSearch(traceStateRef.current.search.query, nodeToScrollTo, 'persist');
       }
     },
-    [setRowAsFocused, traceDispatch, onTraceSearch, scrollRowIntoView, viewManager]
+    [setRowAsFocused, traceDispatch, onTraceSearch, viewManager, traceScheduler]
   );
 
   // Setup the middleware for the trace reducer
@@ -809,11 +868,11 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
         payload: list_width,
       });
     }
-    viewManager.on('divider resize end', onDividerResizeEnd);
+    traceScheduler.on('divider resize end', onDividerResizeEnd);
     return () => {
-      viewManager.off('divider resize end', onDividerResizeEnd);
+      traceScheduler.off('divider resize end', onDividerResizeEnd);
     };
-  }, [viewManager, traceDispatch]);
+  }, [traceScheduler, traceDispatch]);
 
   // Sync part of the state with the URL
   const traceQueryStateSync = useMemo(() => {
@@ -840,11 +899,16 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
       return undefined;
     }
 
-    viewManager.initializeTraceSpace([tree.root.space[0], 0, tree.root.space[1], 1]);
+    traceScheduler.dispatch('initialize trace space', [
+      tree.root.space[0],
+      0,
+      tree.root.space[1],
+      1,
+    ]);
 
-    // Whenever the timeline changes, update the trace space
+    // Whenever the timeline changes, update the trace space and trigger a redraw
     const onTraceTimelineChange = (s: [number, number]) => {
-      viewManager.updateTraceSpace(s[0], s[1]);
+      traceScheduler.dispatch('set trace space', [s[0], 0, s[1], 1]);
     };
 
     tree.on('trace timeline change', onTraceTimelineChange);
@@ -852,7 +916,7 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
     return () => {
       tree.off('trace timeline change', onTraceTimelineChange);
     };
-  }, [viewManager, tree]);
+  }, [viewManager, traceScheduler, tree]);
 
   return (
     <Fragment>
@@ -876,6 +940,7 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
           onTraceSearch={onTraceSearch}
           previouslyFocusedNodeRef={previouslyFocusedNodeRef}
           manager={viewManager}
+          scheduler={traceScheduler}
           forceRerender={forceRender}
         />
 
@@ -896,6 +961,7 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
           traceGridRef={traceGridRef}
           traces={props.trace}
           manager={viewManager}
+          scheduler={traceScheduler}
           onTabScrollToNode={onTabScrollToNode}
           onScrollToNode={onScrollToNode}
           rootEventResults={props.rootEvent}

+ 45 - 1
static/app/views/performance/newTraceDetails/trace.tsx

@@ -30,6 +30,10 @@ import {clamp} from 'sentry/utils/profiling/colors/utils';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
+import type {
+  TraceEvents,
+  TraceScheduler,
+} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceScheduler';
 import {
   useVirtualizedList,
   type VirtualizedRow,
@@ -158,6 +162,7 @@ interface TraceProps {
   ) => void;
   previouslyFocusedNodeRef: React.MutableRefObject<TraceTreeNode<TraceTree.NodeValue> | null>;
   rerender: () => void;
+  scheduler: TraceScheduler;
   scrollQueueRef: React.MutableRefObject<
     | {
         eventId?: string;
@@ -180,6 +185,7 @@ export function Trace({
   onTraceSearch,
   onTraceLoad,
   rerender,
+  scheduler,
   initializedRef,
   forceRerender,
 }: TraceProps) {
@@ -206,6 +212,43 @@ export function Trace({
   const traceStateRef = useRef<TraceReducerState>(traceState);
   traceStateRef.current = traceState;
 
+  useLayoutEffect(() => {
+    const onTraceViewChange: TraceEvents['set trace view'] = () => {
+      manager.recomputeTimelineIntervals();
+      manager.recomputeSpanToPXMatrix();
+      manager.draw();
+    };
+    const onPhysicalSpaceChange: TraceEvents['set container physical space'] = () => {
+      manager.recomputeTimelineIntervals();
+      manager.recomputeSpanToPXMatrix();
+      manager.draw();
+    };
+    const onTraceSpaceChange: TraceEvents['initialize trace space'] = () => {
+      manager.recomputeTimelineIntervals();
+      manager.recomputeSpanToPXMatrix();
+      manager.draw();
+    };
+    const onDividerResize: TraceEvents['divider resize'] = view => {
+      manager.recomputeTimelineIntervals();
+      manager.recomputeSpanToPXMatrix();
+      manager.draw(view);
+    };
+
+    scheduler.on('set trace view', onTraceViewChange);
+    scheduler.on('set trace space', onTraceSpaceChange);
+    scheduler.on('set container physical space', onPhysicalSpaceChange);
+    scheduler.on('initialize trace space', onTraceSpaceChange);
+    scheduler.on('divider resize', onDividerResize);
+
+    return () => {
+      scheduler.off('set trace view', onTraceViewChange);
+      scheduler.off('set trace space', onTraceSpaceChange);
+      scheduler.off('set container physical space', onPhysicalSpaceChange);
+      scheduler.off('initialize trace space', onTraceSpaceChange);
+      scheduler.off('divider resize', onDividerResize);
+    };
+  }, [manager, scheduler]);
+
   useLayoutEffect(() => {
     if (initializedRef.current) {
       return;
@@ -444,6 +487,7 @@ export function Trace({
     items: trace.list,
     container: scrollContainer,
     render: render,
+    scheduler,
   });
 
   const traceNode = trace.root.children[0];
@@ -499,7 +543,7 @@ export function Trace({
             >
               <div className="TraceIndicatorLabel">
                 {indicatorTimestamp > 0
-                  ? formatTraceDuration(manager.trace_view.x + indicatorTimestamp)
+                  ? formatTraceDuration(manager.view.trace_view.x + indicatorTimestamp)
                   : '0s'}
               </div>
               <div className="TraceIndicatorLine" />

+ 4 - 3
static/app/views/performance/newTraceDetails/traceDrawer/traceDrawer.tsx

@@ -32,6 +32,7 @@ import {
   usePassiveResizableDrawer,
   type UsePassiveResizableDrawerOptions,
 } from 'sentry/views/performance/newTraceDetails/traceDrawer/usePassiveResizeableDrawer';
+import type {TraceScheduler} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceScheduler';
 import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager';
 import type {
   TraceReducerAction,
@@ -63,6 +64,7 @@ type TraceDrawerProps = {
   onTabScrollToNode: (node: TraceTreeNode<TraceTree.NodeValue>) => void;
   replayRecord: ReplayRecord | null;
   rootEventResults: UseApiQueryResult<EventTransaction, RequestError>;
+  scheduler: TraceScheduler;
   trace: TraceTree;
   traceEventView: EventView;
   traceGridRef: HTMLElement | null;
@@ -131,8 +133,7 @@ export function TraceDrawer(props: TraceDrawerProps) {
         props.manager.container
       ) {
         const {width, height} = props.manager.container.getBoundingClientRect();
-        props.manager.initializePhysicalSpace(width, height);
-        props.manager.draw();
+        props.scheduler.dispatch('set container physical space', [0, 0, width, height]);
       }
 
       minimized = minimized ?? traceStateRef.current.preferences.drawer.minimized;
@@ -189,7 +190,7 @@ export function TraceDrawer(props: TraceDrawerProps) {
         props.traceGridRef.style.gridTemplateRows = '1fr auto';
       }
     },
-    [props.traceGridRef, props.manager, traceDispatch]
+    [props.traceGridRef, props.manager, props.scheduler, traceDispatch]
   );
 
   const [drawerRef, setDrawerRef] = useState<HTMLDivElement | null>(null);

+ 41 - 0
static/app/views/performance/newTraceDetails/traceRenderers/traceScheduler.spec.tsx

@@ -0,0 +1,41 @@
+import {TraceScheduler} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceScheduler';
+
+describe('TraceScheduler', () => {
+  it('respects priority', () => {
+    const scheduler = new TraceScheduler();
+
+    const highPriority = jest.fn().mockImplementation(() => {
+      expect(lowPriority).not.toHaveBeenCalled();
+    });
+    const lowPriority = jest.fn().mockImplementation(() => {
+      expect(highPriority).toHaveBeenCalled();
+    });
+
+    // Enquee high priority after low priority
+    scheduler.on('draw', lowPriority, 10);
+    scheduler.on('draw', highPriority, 1);
+    scheduler.dispatch('draw');
+  });
+  it('once', () => {
+    const scheduler = new TraceScheduler();
+
+    const cb = jest.fn();
+    scheduler.once('draw', cb);
+
+    scheduler.dispatch('draw');
+    scheduler.dispatch('draw');
+
+    expect(cb).toHaveBeenCalledTimes(1);
+  });
+
+  it('off', () => {
+    const scheduler = new TraceScheduler();
+    const cb = jest.fn();
+
+    scheduler.on('draw', cb);
+    scheduler.off('draw', cb);
+
+    scheduler.dispatch('draw');
+    expect(cb).not.toHaveBeenCalled();
+  });
+});

+ 95 - 0
static/app/views/performance/newTraceDetails/traceRenderers/traceScheduler.tsx

@@ -0,0 +1,95 @@
+type ArgumentTypes<F> = F extends (...args: infer A) => any ? A : never;
+type EventStore = {
+  [K in keyof TraceEvents]: Array<[number, TraceEvents[K]]>;
+};
+
+export enum TraceEventPriority {
+  LOW = 10,
+  MEDIUM = 5,
+  HIGH = 1,
+}
+export interface TraceEvents {
+  ['divider resize']: (view: {list: number; span_list: number}) => void;
+  ['divider resize end']: (list_width: number) => void;
+  ['draw']: (options?: {list?: number; span_list?: number}) => void;
+  ['initialize trace space']: (
+    space: [x: number, y: number, width: number, height: number]
+  ) => void;
+  ['initialize virtualized list']: () => void;
+  ['set container physical space']: (
+    container_space: [x: number, y: number, width: number, height: number]
+  ) => void;
+  ['set trace space']: (
+    space: [x: number, y: number, width: number, height: number]
+  ) => void;
+  ['set trace view']: (view: {width?: number; x?: number}) => void;
+}
+
+export class TraceScheduler {
+  events: EventStore = {
+    ['initialize virtualized list']: new Array<
+      [TraceEventPriority, TraceEvents['initialize virtualized list']]
+    >(),
+    ['set container physical space']: new Array<
+      [TraceEventPriority, TraceEvents['set container physical space']]
+    >(),
+    ['initialize trace space']: new Array<
+      [TraceEventPriority, TraceEvents['initialize trace space']]
+    >(),
+    ['set trace space']: new Array<
+      [TraceEventPriority, TraceEvents['set trace space']]
+    >(),
+    ['divider resize end']: new Array<
+      [TraceEventPriority, TraceEvents['divider resize end']]
+    >(),
+    ['divider resize']: new Array<[TraceEventPriority, TraceEvents['divider resize']]>(),
+    ['set trace view']: new Array<[TraceEventPriority, TraceEvents['set trace view']]>(),
+    ['draw']: new Array<[TraceEventPriority, TraceEvents['draw']]>(),
+  };
+
+  once<K extends keyof TraceEvents>(eventName: K, cb: TraceEvents[K]) {
+    const wrapper = (...args: any[]) => {
+      // @ts-expect-error
+      cb(...args);
+      this.off(eventName, wrapper);
+    };
+
+    this.on(eventName, wrapper);
+  }
+
+  on<K extends keyof TraceEvents>(
+    eventName: K,
+    cb: TraceEvents[K],
+    priority: TraceEventPriority = 10
+  ): void {
+    const arr = this.events[eventName];
+    if (!arr || arr.some(a => a[1] === cb)) {
+      return;
+    }
+    arr.push([priority, cb]);
+    arr.sort((a, b) => a[0] - b[0]);
+  }
+
+  off<K extends keyof TraceEvents>(eventName: K, cb: TraceEvents[K]): void {
+    const arr = this.events[eventName];
+
+    if (!arr) {
+      return;
+    }
+
+    // @ts-expect-error - filter out the callback
+    this.events[eventName] = arr.filter(a => a[1] !== cb) as unknown as Array<
+      [TraceEventPriority, K]
+    >;
+  }
+
+  dispatch<K extends keyof TraceEvents>(
+    eventName: K,
+    ...args: ArgumentTypes<TraceEvents[K]>
+  ): void {
+    for (const [_priority, handler] of this.events[eventName]) {
+      // @ts-expect-error
+      handler(...args);
+    }
+  }
+}

+ 42 - 0
static/app/views/performance/newTraceDetails/traceRenderers/traceView.spec.tsx

@@ -0,0 +1,42 @@
+import {TraceView} from './traceView';
+
+describe('TraceView', () => {
+  it('does not allow setting trace view width to 0', () => {
+    const view = new TraceView();
+
+    view.setTraceView({width: 0});
+    expect(view.trace_view.width).toBeGreaterThan(0);
+  });
+
+  describe('getConfigSpaceCursor', () => {
+    it('returns the correct x position', () => {
+      const view = new TraceView();
+
+      view.setTraceSpace([0, 0, 100, 1]);
+      view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
+
+      expect(view.getConfigSpaceCursor({x: 500, y: 0})).toEqual([50, 0]);
+    });
+
+    it('returns the correct x position when view scaled', () => {
+      const view = new TraceView();
+
+      view.setTraceSpace([0, 0, 100, 1]);
+      view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
+      view.setTraceView({x: 50, width: 50});
+
+      expect(view.getConfigSpaceCursor({x: 500, y: 0})).toEqual([75, 0]);
+    });
+
+    it('returns the correct x position when view is offset', () => {
+      const view = new TraceView();
+
+      view.setTraceSpace([0, 0, 100, 1]);
+      view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
+      view.setTraceView({x: 50, width: 50});
+
+      // Half of the right quadrant
+      expect(view.getConfigSpaceCursor({x: 500, y: 0})).toEqual([75, 0]);
+    });
+  });
+});

+ 76 - 5
static/app/views/performance/newTraceDetails/traceRenderers/traceView.tsx

@@ -1,8 +1,78 @@
 import {mat3} from 'gl-matrix';
 
+import clamp from 'sentry/utils/number/clamp';
+
 // Computes the transformation matrix that is used to render scaled
 // elements to the DOM and draw the view.
 export class TraceView {
+  // Represents the space of the entire trace, for example
+  // a trace starting at 0 and ending at 1000 would have a space of [0, 1000]
+  to_origin: number = 0;
+  trace_space: DOMView = DOMView.Empty();
+  // The view defines what the user is currently looking at, it is a subset
+  // of the trace space. For example, if the user is currently looking at the
+  // trace from 500 to 1000, the view would be represented by [x, width] = [500, 500]
+  trace_view: DOMView = DOMView.Empty();
+  // Represents the pixel space of the entire trace - this is the container
+  // that we render to. For example, if the container is 1000px wide, the
+  // pixel space would be [0, 1000]
+  trace_physical_space: DOMView = DOMView.Empty();
+  // the encapsulating container that the entire view is rendered to
+  trace_container_physical_space: DOMView = DOMView.Empty();
+  public readonly MAX_ZOOM_PRECISION_MS = 1;
+
+  setTracePhysicalSpace(
+    container_space: [x: number, y: number, width: number, height: number],
+    space: [x: number, y: number, width: number, height: number]
+  ) {
+    this.trace_container_physical_space = new DOMView(
+      0,
+      0,
+      container_space[2],
+      container_space[3]
+    );
+    this.trace_physical_space = new DOMView(0, 0, space[2], space[3]);
+  }
+
+  setTraceSpace(space: [x: number, y: number, width: number, height: number]) {
+    this.to_origin = space[0];
+    this.trace_space = new DOMView(0, 0, space[2], space[3]);
+    this.trace_view = new DOMView(0, 0, space[2], space[3]);
+  }
+
+  setTraceView(view: {width?: number; x?: number}) {
+    // In cases where a trace might have a single error, there is no concept of a timeline
+    if (this.trace_view.width === 0) {
+      return;
+    }
+
+    const x = view.x ?? this.trace_view.x;
+    const width = view.width ?? this.trace_view.width;
+
+    this.trace_view.width = clamp(
+      width,
+      this.MAX_ZOOM_PRECISION_MS,
+      this.trace_space.width - this.trace_view.x
+    );
+    this.trace_view.x = clamp(
+      x,
+      0,
+      Math.max(this.trace_space.width - width, this.MAX_ZOOM_PRECISION_MS)
+    );
+  }
+
+  getConfigSpaceCursor(cursor: {x: number; y: number}): [number, number] {
+    const left_percentage = cursor.x / this.trace_physical_space.width;
+    const left_view = left_percentage * this.trace_view.width;
+
+    return [this.trace_view.x + left_view, 0];
+  }
+}
+
+/**
+ * Helper class that handles computing transformations between different views to and from DOM space
+ */
+class DOMView {
   public x: number;
   public y: number;
   public width: number;
@@ -15,18 +85,19 @@ export class TraceView {
     this.height = height;
   }
 
-  static From(view: TraceView): TraceView {
-    return new TraceView(view.x, view.y, view.width, view.height);
+  static From(view: DOMView): DOMView {
+    return new DOMView(view.x, view.y, view.width, view.height);
   }
-  static Empty(): TraceView {
-    return new TraceView(0, 0, 1000, 1);
+
+  static Empty(): DOMView {
+    return new DOMView(0, 0, 1000, 1);
   }
 
   serialize() {
     return [this.x, this.y, this.width, this.height];
   }
 
-  between(to: TraceView): mat3 {
+  between(to: DOMView): mat3 {
     return mat3.fromValues(
       to.width / this.width,
       0,

+ 4 - 2
static/app/views/performance/newTraceDetails/traceRenderers/traceVirtualizedList.tsx

@@ -5,6 +5,7 @@ import type {
   TraceTree,
   TraceTreeNode,
 } from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
+import type {TraceScheduler} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceScheduler';
 import {
   VirtualizedList,
   type VirtualizedViewManager,
@@ -21,6 +22,7 @@ interface UseVirtualizedListProps {
   items: ReadonlyArray<TraceTreeNode<TraceTree.NodeValue>>;
   manager: VirtualizedViewManager;
   render: (item: VirtualizedRow) => React.ReactNode;
+  scheduler: TraceScheduler;
 }
 
 interface UseVirtualizedListResult {
@@ -148,7 +150,7 @@ export const useVirtualizedList = (
     scrollContainerRef.current!.style.willChange = 'transform';
     scrollContainerRef.current!.style.height = `${props.items.length * 24}px`;
 
-    managerRef.current.dispatch('virtualized list init');
+    props.scheduler.dispatch('initialize virtualized list');
 
     maybeToggleScrollbar(
       props.container,
@@ -225,7 +227,7 @@ export const useVirtualizedList = (
     return () => {
       props.container?.removeEventListener('scroll', onScroll);
     };
-  }, [props.container, props.items, props.items.length, props.manager]);
+  }, [props.container, props.items, props.items.length, props.manager, props.scheduler]);
 
   useLayoutEffect(() => {
     if (!list.current || !styleCache.current || !renderCache.current) {

+ 134 - 125
static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.spec.tsx

@@ -3,6 +3,8 @@ import {OrganizationFixture} from 'sentry-fixture/organization';
 import type {RawSpanType} from 'sentry/components/events/interfaces/spans/types';
 import {EntryType, type Event} from 'sentry/types/event';
 import type {TraceSplitResults} from 'sentry/utils/performance/quickTrace/types';
+import {TraceScheduler} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceScheduler';
+import {TraceView} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceView';
 import {
   type VirtualizedList,
   VirtualizedViewManager,
@@ -108,63 +110,85 @@ const EVENT_REQUEST_URL =
 
 describe('VirtualizedViewManger', () => {
   it('initializes space', () => {
-    const manager = new VirtualizedViewManager({
-      list: {width: 0.5},
-      span_list: {width: 0.5},
-    });
-
-    manager.initializeTraceSpace([10_000, 0, 1000, 1]);
-
-    expect(manager.trace_space.serialize()).toEqual([0, 0, 1000, 1]);
-    expect(manager.trace_view.serialize()).toEqual([0, 0, 1000, 1]);
+    const manager = new VirtualizedViewManager(
+      {
+        list: {width: 0.5},
+        span_list: {width: 0.5},
+      },
+      new TraceScheduler(),
+      new TraceView()
+    );
+
+    manager.view.setTraceSpace([10_000, 0, 1000, 1]);
+
+    expect(manager.view.trace_space.serialize()).toEqual([0, 0, 1000, 1]);
+    expect(manager.view.trace_view.serialize()).toEqual([0, 0, 1000, 1]);
   });
 
   it('initializes physical space', () => {
-    const manager = new VirtualizedViewManager({
-      list: {width: 0.5},
-      span_list: {width: 0.5},
-    });
-
-    manager.initializePhysicalSpace(1000, 1);
-
-    expect(manager.container_physical_space.serialize()).toEqual([0, 0, 1000, 1]);
-    expect(manager.trace_physical_space.serialize()).toEqual([0, 0, 500, 1]);
+    const manager = new VirtualizedViewManager(
+      {
+        list: {width: 0.5},
+        span_list: {width: 0.5},
+      },
+      new TraceScheduler(),
+      new TraceView()
+    );
+
+    manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 500, 1]);
+
+    expect(manager.view.trace_container_physical_space.serialize()).toEqual([
+      0, 0, 1000, 1,
+    ]);
+    expect(manager.view.trace_physical_space.serialize()).toEqual([0, 0, 500, 1]);
   });
 
   describe('computeSpanCSSMatrixTransform', () => {
     it('enforces min scaling', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
+      const manager = new VirtualizedViewManager(
+        {
+          list: {width: 0},
+          span_list: {width: 1},
+        },
+        new TraceScheduler(),
+        new TraceView()
+      );
 
-      manager.initializeTraceSpace([0, 0, 1000, 1]);
-      manager.initializePhysicalSpace(1000, 1);
+      manager.view.setTraceSpace([0, 0, 1000, 1]);
+      manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
 
       expect(manager.computeSpanCSSMatrixTransform([0, 0.1])).toEqual([
         0.001, 0, 0, 1, 0, 0,
       ]);
     });
     it('computes width scaling correctly', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
+      const manager = new VirtualizedViewManager(
+        {
+          list: {width: 0},
+          span_list: {width: 1},
+        },
+        new TraceScheduler(),
+        new TraceView()
+      );
 
-      manager.initializeTraceSpace([0, 0, 100, 1]);
-      manager.initializePhysicalSpace(1000, 1);
+      manager.view.setTraceSpace([0, 0, 100, 1]);
+      manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
 
       expect(manager.computeSpanCSSMatrixTransform([0, 100])).toEqual([1, 0, 0, 1, 0, 0]);
     });
 
     it('computes x position correctly', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
+      const manager = new VirtualizedViewManager(
+        {
+          list: {width: 0},
+          span_list: {width: 1},
+        },
+        new TraceScheduler(),
+        new TraceView()
+      );
 
-      manager.initializeTraceSpace([0, 0, 1000, 1]);
-      manager.initializePhysicalSpace(1000, 1);
+      manager.view.setTraceSpace([0, 0, 1000, 1]);
+      manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
 
       expect(manager.computeSpanCSSMatrixTransform([50, 1000])).toEqual([
         1, 0, 0, 1, 50, 0,
@@ -172,13 +196,17 @@ describe('VirtualizedViewManger', () => {
     });
 
     it('computes span x position correctly', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
+      const manager = new VirtualizedViewManager(
+        {
+          list: {width: 0},
+          span_list: {width: 1},
+        },
+        new TraceScheduler(),
+        new TraceView()
+      );
 
-      manager.initializeTraceSpace([0, 0, 1000, 1]);
-      manager.initializePhysicalSpace(1000, 1);
+      manager.view.setTraceSpace([0, 0, 1000, 1]);
+      manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
 
       expect(manager.computeSpanCSSMatrixTransform([50, 1000])).toEqual([
         1, 0, 0, 1, 50, 0,
@@ -187,26 +215,34 @@ describe('VirtualizedViewManger', () => {
 
     describe('when start is not 0', () => {
       it('computes width scaling correctly', () => {
-        const manager = new VirtualizedViewManager({
-          list: {width: 0},
-          span_list: {width: 1},
-        });
+        const manager = new VirtualizedViewManager(
+          {
+            list: {width: 0},
+            span_list: {width: 1},
+          },
+          new TraceScheduler(),
+          new TraceView()
+        );
 
-        manager.initializeTraceSpace([100, 0, 100, 1]);
-        manager.initializePhysicalSpace(1000, 1);
+        manager.view.setTraceSpace([100, 0, 100, 1]);
+        manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
 
         expect(manager.computeSpanCSSMatrixTransform([100, 100])).toEqual([
           1, 0, 0, 1, 0, 0,
         ]);
       });
       it('computes x position correctly when view is offset', () => {
-        const manager = new VirtualizedViewManager({
-          list: {width: 0},
-          span_list: {width: 1},
-        });
+        const manager = new VirtualizedViewManager(
+          {
+            list: {width: 0},
+            span_list: {width: 1},
+          },
+          new TraceScheduler(),
+          new TraceView()
+        );
 
-        manager.initializeTraceSpace([100, 0, 100, 1]);
-        manager.initializePhysicalSpace(1000, 1);
+        manager.view.setTraceSpace([100, 0, 100, 1]);
+        manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
 
         expect(manager.computeSpanCSSMatrixTransform([100, 100])).toEqual([
           1, 0, 0, 1, 0, 0,
@@ -215,87 +251,56 @@ describe('VirtualizedViewManger', () => {
     });
   });
 
-  describe('computeTransformXFromTimestamp', () => {
+  describe('transformXFromTimestamp', () => {
     it('computes x position correctly', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
+      const manager = new VirtualizedViewManager(
+        {
+          list: {width: 0},
+          span_list: {width: 1},
+        },
+        new TraceScheduler(),
+        new TraceView()
+      );
 
-      manager.initializeTraceSpace([0, 0, 1000, 1]);
-      manager.initializePhysicalSpace(1000, 1);
+      manager.view.setTraceSpace([0, 0, 1000, 1]);
+      manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
 
-      expect(manager.computeTransformXFromTimestamp(50)).toEqual(50);
+      expect(manager.transformXFromTimestamp(50)).toEqual(50);
     });
 
     it('computes x position correctly when view is offset', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
+      const manager = new VirtualizedViewManager(
+        {
+          list: {width: 0},
+          span_list: {width: 1},
+        },
+        new TraceScheduler(),
+        new TraceView()
+      );
 
-      manager.initializeTraceSpace([50, 0, 1000, 1]);
-      manager.initializePhysicalSpace(1000, 1);
+      manager.view.setTraceSpace([50, 0, 1000, 1]);
+      manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
 
-      manager.trace_view.x = 50;
+      manager.view.trace_view.x = 50;
 
-      expect(manager.computeTransformXFromTimestamp(-50)).toEqual(-150);
+      expect(manager.transformXFromTimestamp(-50)).toEqual(-150);
     });
 
     it('when view is offset and scaled', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
-
-      manager.initializeTraceSpace([50, 0, 100, 1]);
-      manager.initializePhysicalSpace(1000, 1);
-
-      manager.trace_view.width = 50;
-      manager.trace_view.x = 50;
-
-      expect(Math.round(manager.computeTransformXFromTimestamp(75))).toEqual(-250);
-    });
-  });
-
-  describe('getConfigSpaceCursor', () => {
-    it('returns the correct x position', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
-
-      manager.initializeTraceSpace([0, 0, 100, 1]);
-      manager.initializePhysicalSpace(1000, 1);
-
-      expect(manager.getConfigSpaceCursor({x: 500, y: 0})).toEqual([50, 0]);
-    });
-
-    it('returns the correct x position when view scaled', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
-
-      manager.initializeTraceSpace([0, 0, 100, 1]);
-      manager.initializePhysicalSpace(1000, 1);
-
-      manager.trace_view.x = 50;
-      manager.trace_view.width = 50;
-      expect(manager.getConfigSpaceCursor({x: 500, y: 0})).toEqual([75, 0]);
-    });
-
-    it('returns the correct x position when view is offset', () => {
-      const manager = new VirtualizedViewManager({
-        list: {width: 0},
-        span_list: {width: 1},
-      });
+      const manager = new VirtualizedViewManager(
+        {
+          list: {width: 0},
+          span_list: {width: 1},
+        },
+        new TraceScheduler(),
+        new TraceView()
+      );
 
-      manager.initializeTraceSpace([0, 0, 100, 1]);
-      manager.initializePhysicalSpace(1000, 1);
+      manager.view.setTraceSpace([100, 0, 1000, 1]);
+      manager.view.setTracePhysicalSpace([0, 0, 1000, 1], [0, 0, 1000, 1]);
+      manager.view.setTraceView({width: 500, x: 500});
 
-      manager.trace_view.x = 50;
-      expect(manager.getConfigSpaceCursor({x: 500, y: 0})).toEqual([100, 0]);
+      expect(Math.round(manager.transformXFromTimestamp(100))).toEqual(-500);
     });
   });
 
@@ -303,10 +308,14 @@ describe('VirtualizedViewManger', () => {
     const organization = OrganizationFixture();
     const api = new MockApiClient();
 
-    const manager = new VirtualizedViewManager({
-      list: {width: 0.5},
-      span_list: {width: 0.5},
-    });
+    const manager = new VirtualizedViewManager(
+      {
+        list: {width: 0.5},
+        span_list: {width: 0.5},
+      },
+      new TraceScheduler(),
+      new TraceView()
+    );
 
     it('scrolls to root node', async () => {
       const tree = TraceTree.FromTrace(

File diff suppressed because it is too large
+ 192 - 314
static/app/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager.tsx


Some files were not shown because too many files changed in this diff