diff --git a/.changeset/trace-viewer-zoom-render-isolation.md b/.changeset/trace-viewer-zoom-render-isolation.md new file mode 100644 index 0000000000..af8f791a2b --- /dev/null +++ b/.changeset/trace-viewer-zoom-render-isolation.md @@ -0,0 +1,5 @@ +--- +'@workflow/web-shared': patch +--- + +Fix the trace viewer click-to-zoom animation jumping/stuttering on large traces. The easing clock now starts on the first animation frame (so a heavy detail-panel mount can't make the zoom skip ahead), the panel opens as a low-priority transition so it doesn't block the animation, and the panel/event list/marker tooltips no longer do redundant work on every animation frame. diff --git a/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx b/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx index 671e926aec..16169e85dd 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx @@ -23,6 +23,7 @@ import type { OffscreenMarkers, Segment, SegmentStatus, + SpanGap, TimeMarker, } from '../utils'; import { @@ -50,6 +51,9 @@ const TINY_BAR_BOX_SIZE_PX = 24; const TINY_BAR_WIDTH_PX = 4; export const TIMELINE_PADDING_PX = 16; +/** Stable empty reference so the gaps memo doesn't churn when Alt isn't held. */ +const EMPTY_GAPS: SpanGap[] = []; + const SEGMENT_CLASSES: Record = { queued: 'bg-gray-400 border border-gray-500', pending: 'bg-gray-200 border border-gray-500', @@ -669,9 +673,13 @@ export function Timeline({ return () => ro.disconnect(); }, []); + // Only needed for the Alt-key delta overlay. Gating on `altHeld` keeps this + // O(spans) pass off the zoom/pan animation path — `viewStart`/`viewEnd` change + // on every frame, so without the guard this recomputes over the whole trace + // each frame even though nothing consumes it unless Alt is held. const gaps = useMemo( - () => computeSpanGaps(spans, viewStart, viewEnd), - [spans, viewStart, viewEnd] + () => (altHeld ? computeSpanGaps(spans, viewStart, viewEnd) : EMPTY_GAPS), + [altHeld, spans, viewStart, viewEnd] ); return ( diff --git a/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx b/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx index 2bb339ac59..7d58a13bd1 100644 --- a/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react'; import { type ReactNode, + startTransition, useCallback, useDeferredValue, useEffect, @@ -29,6 +30,7 @@ import { } from '../sidebar/entity-detail-panel'; import { useSidebarData } from '../sidebar/sidebar-data-context'; import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing'; +import { ContextCardProvider } from '../ui/context-card'; import { IconButton } from '../ui/icon-button'; import { Kbd } from '../ui/kbd'; import { Spinner } from '../ui/spinner'; @@ -98,10 +100,19 @@ function useAnimatedViewport(initial: Viewport) { } const from = currentRef.current; - const anim = { raf: 0, from, to: target, start: performance.now() }; + // Anchor the easing clock to the first delivered frame, not to call time. + // Selecting a span mounts the detail panel in the same tick, and on large + // production traces that synchronous render can delay the first animation + // frame by tens/hundreds of ms. With a call-time clock, that delay makes + // the zoom skip ahead — or jump straight to the end — which reads exactly + // like "the animation broke / reduced motion is on". Starting the clock on + // the first frame keeps the zoom smooth no matter how late it begins. + const anim = { raf: 0, from, to: target, start: 0 }; const tick = () => { - const t = Math.min((performance.now() - anim.start) / 150, 1); + const now = performance.now(); + if (anim.start === 0) anim.start = now; + const t = Math.min((now - anim.start) / 150, 1); const e = 1 - (1 - t) * (1 - t); setViewportState({ start: anim.from.start + (anim.to.start - anim.from.start) * e, @@ -178,14 +189,21 @@ export function NewTraceViewer({ }: NewTraceViewerProps): ReactNode { return ( - - - + {/* One shared provider for every timeline-marker TimestampTooltip. Without + it, each marker tick self-mounts its own ContextCardProvider — a + full-screen portal + ResizeObserver + document scroll listener — and + those mount/unmount in bulk as bars cross the tiny↔full threshold on + each zoom frame. Sharing one provider is the design's intended usage. */} + + + + + ); } @@ -412,8 +430,16 @@ function NewTraceViewerContent({ clearActiveSpan(); return; } - setActiveSpan(spanId); + // Start the zoom first (urgent), then open the detail panel as a + // low-priority transition. On large production traces the panel's first + // render is heavy; as an urgent update it blocks the animation's opening + // frames (the zoom then jumps instead of easing). As a transition, React + // keeps the rAF-driven viewport updates responsive and renders the panel + // without blocking them. focusViewportOnSpan(spanId); + startTransition(() => { + setActiveSpan(spanId); + }); }, [ cancelPendingZoom, @@ -644,6 +670,116 @@ function NewTraceViewerContent({ nextSpanIdRef.current = nextSpanId; navigateToSpanRef.current = navigateToSpan; + // The viewport animation (`animateTo`) updates `viewport` state ~60×/sec, + // re-rendering this component on every frame. The event list and the detail + // panel don't depend on the viewport, so we memoize them into stable element + // references — React bails out of reconciling a child subtree when its + // element is referentially identical across renders. Without this, selecting + // a span re-reconciles the entire (recursive) detail-panel JSON tree on every + // zoom frame, which is what made the click-to-zoom feel janky. + const eventList = useMemo( + () => ( + + ), + [trace.spans, activeSpanId, searchResult, handleSelectSpan] + ); + + const detailPanel = useMemo(() => { + if (!activeSpan) return null; + return ( + + ); + }, [ + activeSpan, + selectedSpanName, + handleSelectPrevSpan, + prevSpanId, + handleSelectNextSpan, + nextSpanId, + handleClearActiveSpan, + sidebar, + selectedSpan, + ]); + return (
- + {eventList}
{isLoadingMore ? (
@@ -761,83 +892,7 @@ function NewTraceViewerContent({
{/* Detail panel */} - {activeSpan ? ( - - ) : null} + {detailPanel} 1}