diff --git a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx index 5a9f00cecf9de..5b0fb28f1b191 100644 --- a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx +++ b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx @@ -1,9 +1,10 @@ -import type {CSSProperties, MouseEvent} from 'react'; +import type {CSSProperties, ReactNode} from 'react'; import {isValidElement, memo, useCallback} from 'react'; import styled from '@emotion/styled'; import beautify from 'js-beautify'; import ProjectAvatar from 'sentry/components/avatar/projectAvatar'; +import {Button} from 'sentry/components/button'; import {CodeSnippet} from 'sentry/components/codeSnippet'; import {Flex} from 'sentry/components/container/flex'; import ErrorBoundary from 'sentry/components/errorBoundary'; @@ -13,6 +14,7 @@ import PanelItem from 'sentry/components/panels/panelItem'; import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton'; import {useReplayContext} from 'sentry/components/replays/replayContext'; import {useReplayGroupContext} from 'sentry/components/replays/replayGroupContext'; +import StructuredEventData from 'sentry/components/structuredEventData'; import Timeline from 'sentry/components/timeline'; import {useHasNewTimelineUI} from 'sentry/components/timeline/utils'; import {Tooltip} from 'sentry/components/tooltip'; @@ -21,18 +23,22 @@ import {space} from 'sentry/styles/space'; import type {Extraction} from 'sentry/utils/replays/extractHtml'; import {getReplayDiffOffsetsFromFrame} from 'sentry/utils/replays/getDiffTimestamps'; import getFrameDetails from 'sentry/utils/replays/getFrameDetails'; +import useExtractDomNodes from 'sentry/utils/replays/hooks/useExtractDomNodes'; import type ReplayReader from 'sentry/utils/replays/replayReader'; import type { ErrorFrame, FeedbackFrame, HydrationErrorFrame, ReplayFrame, + WebVitalFrame, } from 'sentry/utils/replays/types'; import { isBreadcrumbFrame, isErrorFrame, isFeedbackFrame, isHydrationErrorFrame, + isSpanFrame, + isWebVitalFrame, } from 'sentry/utils/replays/types'; import type {Color} from 'sentry/utils/theme'; import useOrganization from 'sentry/utils/useOrganization'; @@ -40,18 +46,14 @@ import useProjectFromSlug from 'sentry/utils/useProjectFromSlug'; import IconWrapper from 'sentry/views/replays/detail/iconWrapper'; import TimestampButton from 'sentry/views/replays/detail/timestampButton'; -type MouseCallback = (frame: ReplayFrame, e: React.MouseEvent) => void; +type MouseCallback = (frame: ReplayFrame, nodeId?: number) => void; const FRAMES_WITH_BUTTONS = ['replay.hydrate-error']; interface Props { frame: ReplayFrame; onClick: null | MouseCallback; - onInspectorExpanded: ( - path: string, - expandedState: Record, - event: MouseEvent - ) => void; + onInspectorExpanded: (path: string, expandedState: Record) => void; onMouseEnter: MouseCallback; onMouseLeave: MouseCallback; startTimestampMs: number; @@ -105,15 +107,31 @@ function BreadcrumbItem({ ) : null; }, [frame, replay]); - const renderCodeSnippet = useCallback(() => { - return extraction?.html ? ( - - - {beautify.html(extraction?.html, {indent_size: 2})} - - + const renderWebVital = useCallback(() => { + return isSpanFrame(frame) && isWebVitalFrame(frame) ? ( + ) : null; - }, [extraction?.html]); + }, [expandPaths, frame, onInspectorExpanded, onMouseEnter, onMouseLeave, replay]); + + const renderCodeSnippet = useCallback(() => { + return ( + (!isSpanFrame(frame) || !isWebVitalFrame(frame)) && + extraction?.html?.map(html => ( + + + {beautify.html(html, {indent_size: 2})} + + + )) + ); + }, [extraction?.html, frame]); const renderIssueLink = useCallback(() => { return isErrorFrame(frame) || isFeedbackFrame(frame) ? ( @@ -143,13 +161,17 @@ function BreadcrumbItem({ data-is-error-frame={isErrorFrame(frame)} style={style} className={className} - onClick={e => onClick?.(frame, e)} - onMouseEnter={e => onMouseEnter(frame, e)} - onMouseLeave={e => onMouseLeave(frame, e)} + onClick={event => { + event.stopPropagation(); + onClick?.(frame); + }} + onMouseEnter={() => onMouseEnter(frame)} + onMouseLeave={() => onMouseLeave(frame)} > {renderDescription()} {renderComparisonButton()} + {renderWebVital()} {renderCodeSnippet()} {renderIssueLink()} @@ -160,9 +182,12 @@ function BreadcrumbItem({ onClick?.(frame, e)} - onMouseEnter={e => onMouseEnter(frame, e)} - onMouseLeave={e => onMouseLeave(frame, e)} + onClick={event => { + event.stopPropagation(); + onClick?.(frame); + }} + onMouseEnter={() => onMouseEnter(frame)} + onMouseLeave={() => onMouseLeave(frame)} style={style} className={className} > @@ -184,6 +209,7 @@ function BreadcrumbItem({ {renderDescription()} {renderComparisonButton()} + {renderWebVital()} {renderCodeSnippet()} {renderIssueLink()} @@ -192,6 +218,100 @@ function BreadcrumbItem({ ); } +function WebVitalData({ + replay, + frame, + expandPaths, + onInspectorExpanded, + onMouseEnter, + onMouseLeave, +}: { + expandPaths: string[] | undefined; + frame: WebVitalFrame; + onInspectorExpanded: (path: string, expandedState: Record) => void; + onMouseEnter: MouseCallback; + onMouseLeave: MouseCallback; + replay: ReplayReader | null; +}) { + const {data: frameToExtraction} = useExtractDomNodes({replay}); + const selectors = frameToExtraction?.get(frame)?.selectors; + + const webVitalData = {value: frame.data.value}; + if ( + frame.description === 'cumulative-layout-shift' && + frame.data.attributions && + selectors + ) { + const layoutShifts: {[x: string]: ReactNode[]}[] = []; + for (const attr of frame.data.attributions) { + const elements: ReactNode[] = []; + if ('nodeIds' in attr && Array.isArray(attr.nodeIds)) { + attr.nodeIds.forEach(nodeId => { + selectors.get(nodeId) + ? elements.push( + onMouseEnter(frame, nodeId)} + onMouseLeave={() => onMouseLeave(frame, nodeId)} + > + {t('element')} + {': '} + + {selectors.get(nodeId)} + + + ) + : null; + }); + } + // if we can't find the elements associated with the layout shift, we still show the score with element: unknown + if (!elements.length) { + elements.push( + + {t('element')} + {': '} + {t('unknown')} + + ); + } + layoutShifts.push({[`score ${attr.value}`]: elements}); + } + if (layoutShifts.length) { + webVitalData['Layout shifts'] = layoutShifts; + } + } else if (selectors?.size) { + selectors.forEach((key, value) => { + webVitalData[key] = ( + onMouseEnter(frame, value)} + onMouseLeave={() => onMouseLeave(frame, value)} + > + {t('element')} + {': '} + + {key} + + + ); + }); + } + + return ( + { + onInspectorExpanded( + path, + Object.fromEntries(expandedPaths.map(item => [item, true])) + ); + }} + data={webVitalData} + withAnnotatedText + /> + ); +} + function CrumbHydrationButton({ replay, frame, @@ -381,4 +501,27 @@ const CodeContainer = styled('div')` overflow: auto; `; +const ValueObjectKey = styled('span')` + color: var(--prism-keyword); +`; + +const ValueNull = styled('span')` + font-weight: ${p => p.theme.fontWeightBold}; + color: var(--prism-property); +`; + +const SelectorButton = styled(Button)` + background: none; + border: none; + padding: 0 2px; + border-radius: 2px; + font-weight: ${p => p.theme.fontWeightNormal}; + box-shadow: none; + font-size: ${p => p.theme.fontSizeSmall}; + color: ${p => p.theme.subText}; + margin: 0 ${space(0.5)}; + height: auto; + min-height: auto; +`; + export default memo(BreadcrumbItem); diff --git a/static/app/utils/replays/extractHtml.tsx b/static/app/utils/replays/extractHtml.tsx index cb6333177d539..aedc99f40d6a1 100644 --- a/static/app/utils/replays/extractHtml.tsx +++ b/static/app/utils/replays/extractHtml.tsx @@ -1,25 +1,72 @@ import type {Mirror} from '@sentry-internal/rrweb-snapshot'; import type {ReplayFrame} from 'sentry/utils/replays/types'; +import constructSelector from 'sentry/views/replays/deadRageClick/constructSelector'; export type Extraction = { frame: ReplayFrame; - html: string | null; + html: string[]; + selectors: Map; timestamp: number; }; -export default function extractHtml(nodeId: number, mirror: Mirror): string | null { - const node = mirror.getNode(nodeId); +export default function extractHtmlAndSelector( + nodeIds: number[], + mirror: Mirror +): {html: string[]; selectors: Map} { + const htmlStrings: string[] = []; + const selectors = new Map(); + for (const nodeId of nodeIds) { + const node = mirror.getNode(nodeId); + if (node) { + const html = extractHtml(node); + if (html) { + htmlStrings.push(html); + } + + const selector = extractSelector(node); + if (selector) { + selectors.set(nodeId, selector); + } + } + } + return {html: htmlStrings, selectors}; +} +function extractHtml(node: Node): string | null { const html = - (node && 'outerHTML' in node ? (node.outerHTML as string) : node?.textContent) || ''; + ('outerHTML' in node ? (node.outerHTML as string) : node.textContent) || ''; // Limit document node depth to 2 let truncated = removeNodesAtLevel(html, 2); // If still very long and/or removeNodesAtLevel failed, truncate if (truncated.length > 1500) { truncated = truncated.substring(0, 1500); } - return truncated ? truncated : null; + if (truncated) { + return truncated; + } + return null; +} + +function extractSelector(node: Node): string | null { + const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null; + + if (element) { + return constructSelector({ + alt: element.attributes.getNamedItem('alt')?.nodeValue ?? '', + aria_label: element.attributes.getNamedItem('aria-label')?.nodeValue ?? '', + class: element.attributes.getNamedItem('class')?.nodeValue?.split(' ') ?? [], + component_name: + element.attributes.getNamedItem('data-sentry-component')?.nodeValue ?? '', + id: element.id, + role: element.attributes.getNamedItem('role')?.nodeValue ?? '', + tag: element.tagName.toLowerCase(), + testid: element.attributes.getNamedItem('data-test-id')?.nodeValue ?? '', + title: element.attributes.getNamedItem('title')?.nodeValue ?? '', + }).selector; + } + + return null; } function removeChildLevel(max: number, collection: HTMLCollection, current: number = 0) { diff --git a/static/app/utils/replays/getFrameDetails.tsx b/static/app/utils/replays/getFrameDetails.tsx index a1e409f112a5e..5720b198c218b 100644 --- a/static/app/utils/replays/getFrameDetails.tsx +++ b/static/app/utils/replays/getFrameDetails.tsx @@ -286,31 +286,31 @@ const MAPPER_FOR_FRAME: Record Details> = { case 'good': return { color: 'green300', - description: tct('Good [value]ms', { + description: tct('[value]ms (Good)', { value: frame.data.value.toFixed(2), }), tabKey: TabKey.NETWORK, - title: toTitleCase(explodeSlug(frame.description)), + title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)), icon: , }; case 'needs-improvement': return { color: 'yellow300', - description: tct('Meh [value]ms', { + description: tct('[value]ms (Meh)', { value: frame.data.value.toFixed(2), }), tabKey: TabKey.NETWORK, - title: toTitleCase(explodeSlug(frame.description)), + title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)), icon: , }; default: return { color: 'red300', - description: tct('Poor [value]ms', { + description: tct('[value]ms (Poor)', { value: frame.data.value.toFixed(2), }), tabKey: TabKey.NETWORK, - title: toTitleCase(explodeSlug(frame.description)), + title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)), icon: , }; } diff --git a/static/app/utils/replays/highlightNode.tsx b/static/app/utils/replays/highlightNode.tsx index 40da9a364cf55..630f20ed9d7ea 100644 --- a/static/app/utils/replays/highlightNode.tsx +++ b/static/app/utils/replays/highlightNode.tsx @@ -2,31 +2,31 @@ import type {Replayer} from '@sentry-internal/rrweb'; const DEFAULT_HIGHLIGHT_COLOR = 'rgba(168, 196, 236, 0.75)'; -const highlightsByNodeId: Map = new Map(); +const highlightsByNodeIds: Map = new Map(); const highlightsBySelector: Map = new Map(); type DrawProps = {annotation: string; color: string; spotlight: boolean}; -interface AddHighlightByNodeIdParams extends Partial { - nodeId: number; +interface AddHighlightByNodeIdsParams extends Partial { + nodeIds: number[]; } interface AddHighlightBySelectorParams extends Partial { selector: string; } -type AddHighlightParams = AddHighlightByNodeIdParams | AddHighlightBySelectorParams; +type AddHighlightParams = AddHighlightByNodeIdsParams | AddHighlightBySelectorParams; type RemoveHighlightParams = | { - nodeId: number; + nodeIds: number[]; } | { selector: string; }; export function clearAllHighlights(replayer: Replayer) { - for (const nodeId of highlightsByNodeId.keys()) { - removeHighlightedNode(replayer, {nodeId}); + for (const nodeId of highlightsByNodeIds.keys()) { + removeHighlightedNode(replayer, {nodeIds: [nodeId]}); } for (const selector of highlightsBySelector.keys()) { removeHighlightedNode(replayer, {selector}); @@ -40,11 +40,13 @@ export function clearAllHighlights(replayer: Replayer) { * are creating a new canvas PER highlight. */ export function removeHighlightedNode(replayer: Replayer, props: RemoveHighlightParams) { - if ('nodeId' in props) { - const highlightObj = highlightsByNodeId.get(props.nodeId); - if (highlightObj && replayer.wrapper.contains(highlightObj.canvas)) { - replayer.wrapper.removeChild(highlightObj.canvas); - highlightsByNodeId.delete(props.nodeId); + if ('nodeIds' in props) { + for (const nodeId of props.nodeIds) { + const highlightObj = highlightsByNodeIds.get(nodeId); + if (highlightObj && replayer.wrapper.contains(highlightObj.canvas)) { + replayer.wrapper.removeChild(highlightObj.canvas); + highlightsByNodeIds.delete(nodeId); + } } } else { const highlightObj = highlightsBySelector.get(props.selector); @@ -62,55 +64,53 @@ export function highlightNode(replayer: Replayer, props: AddHighlightParams) { const {wrapper} = replayer; const mirror = replayer.getMirror(); - const node = - 'nodeId' in props - ? mirror.getNode(props.nodeId) - : replayer.iframe.contentDocument?.body.querySelector(props.selector); - - // TODO(replays): There is some sort of race condition here when you "rewind" a replay, - // mirror will be empty and highlight does not get added because node is null - if ( - !node || - !('getBoundingClientRect' in node) || - !replayer.iframe.contentDocument?.body?.contains(node) - ) { - return null; - } - - // Create a new canvas with the same dimensions as the iframe. We may need to - // revisit this strategy as we create a new canvas for every highlight. See - // additional notes in removeHighlight() method. - const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null; + const nodes = + 'nodeIds' in props + ? props.nodeIds.map(nodeId => mirror.getNode(nodeId)) + : [replayer.iframe.contentDocument?.body.querySelector(props.selector)]; + + for (const node of nodes) { + // TODO(replays): There is some sort of race condition here when you "rewind" a replay, + // mirror will be empty and highlight does not get added because node is null + if ( + !node || + !('getBoundingClientRect' in node) || + !replayer.iframe.contentDocument?.body?.contains(node) + ) { + continue; + } - if (!element) { - return null; - } + // Create a new canvas with the same dimensions as the iframe. We may need to + // revisit this strategy as we create a new canvas for every highlight. See + // additional notes in removeHighlight() method. + const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null; - const canvas = document.createElement('canvas'); - canvas.width = Number(replayer.iframe.width); - canvas.height = Number(replayer.iframe.height); - canvas.setAttribute('style', 'position:absolute;'); + if (!element) { + continue; + } - const boundingClientRect = element.getBoundingClientRect(); - const drawProps = { - annotation: props.annotation ?? '', - color: props.color ?? DEFAULT_HIGHLIGHT_COLOR, - spotlight: props.spotlight ?? false, - }; + const canvas = document.createElement('canvas'); + canvas.width = Number(replayer.iframe.width); + canvas.height = Number(replayer.iframe.height); + canvas.setAttribute('style', 'position:absolute;'); - drawCtx(canvas, boundingClientRect, drawProps); + const boundingClientRect = element.getBoundingClientRect(); + const drawProps = { + annotation: props.annotation ?? '', + color: props.color ?? DEFAULT_HIGHLIGHT_COLOR, + spotlight: props.spotlight ?? false, + }; - if ('nodeId' in props) { - highlightsByNodeId.set(props.nodeId, {canvas}); - } else { - highlightsBySelector.set(props.selector, {canvas}); - } + drawCtx(canvas, boundingClientRect, drawProps); - wrapper.insertBefore(canvas, replayer.iframe); + if ('nodeIds' in props) { + highlightsByNodeIds.set(mirror.getId(node), {canvas}); + } else { + highlightsBySelector.set(props.selector, {canvas}); + } - return { - canvas, - }; + wrapper.insertBefore(canvas, replayer.iframe); + } } function drawCtx( diff --git a/static/app/utils/replays/hooks/useCrumbHandlers.tsx b/static/app/utils/replays/hooks/useCrumbHandlers.tsx index 073296eece72e..825fac72e58b1 100644 --- a/static/app/utils/replays/hooks/useCrumbHandlers.tsx +++ b/static/app/utils/replays/hooks/useCrumbHandlers.tsx @@ -36,7 +36,10 @@ function getNodeIdAndLabel(record: RecordType) { }; } if ('nodeId' in data) { - return {nodeId: data.nodeId, annotation: record.data.label}; + return {nodeIds: [data.nodeId], annotation: record.data.label}; + } + if ('nodeIds' in data) { + return {nodeIds: data.nodeIds, annotation: record.data.label}; } return undefined; } @@ -56,7 +59,7 @@ function useCrumbHandlers() { }); const onMouseEnter = useCallback( - (record: RecordType) => { + (record: RecordType, nodeId?: number) => { // This debounces the mouseEnter callback in unison with mouseLeave. // We ensure the pointer remains over the target element before dispatching // state events in order to minimize unnecessary renders. This helps during @@ -68,7 +71,9 @@ function useCrumbHandlers() { setCurrentHoverTime(record.offsetMs); } - const metadata = getNodeIdAndLabel(record); + const metadata = nodeId + ? {annotations: undefined, nodeIds: [nodeId]} + : getNodeIdAndLabel(record); if (metadata) { // XXX: Kind of hacky, but mouseLeave does not fire if you move from a // crumb to a tooltip @@ -83,7 +88,7 @@ function useCrumbHandlers() { ); const onMouseLeave = useCallback( - (record: RecordType) => { + (record: RecordType, nodeId?: number) => { if (mouseEnterCallback.current.id === record) { // If there is a mouseEnter callback queued and we're leaving the node // just cancel the timeout. @@ -94,7 +99,9 @@ function useCrumbHandlers() { mouseEnterCallback.current.timeoutId = null; } else { setCurrentHoverTime(undefined); - const metadata = getNodeIdAndLabel(record); + const metadata = nodeId + ? {annotations: undefined, nodeIds: [nodeId]} + : getNodeIdAndLabel(record); if (metadata) { removeHighlight(metadata); } diff --git a/static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx b/static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx index de4d7c6a25762..049f42b64ca7b 100644 --- a/static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx +++ b/static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx @@ -196,7 +196,7 @@ describe('useInitialTimeOffsetMs', () => { expect(result.current).toStrictEqual({ highlight: { annotation: undefined, - nodeId: 7, + nodeIds: [7], spotlight: true, }, offsetMs: 5 * 60 * 1000, @@ -240,7 +240,7 @@ describe('useInitialTimeOffsetMs', () => { expect(result.current).toStrictEqual({ highlight: { annotation: undefined, - nodeId: 7, + nodeIds: [7], spotlight: true, }, offsetMs: 5 * 60 * 1000, diff --git a/static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx b/static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx index 9eeef11ae98e5..6b2c534449cc1 100644 --- a/static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx +++ b/static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx @@ -143,7 +143,7 @@ async function fromListPageQuery({ return { highlight: { annotation: undefined, - nodeId, + nodeIds: [nodeId], spotlight: true, }, offsetMs: firstTimestmpMs - replayStartTimestampMs, diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index f612447fef263..318c3f59c2fa9 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/static/app/utils/replays/replayReader.tsx @@ -7,7 +7,7 @@ import {defined} from 'sentry/utils'; import domId from 'sentry/utils/domId'; import localStorageWrapper from 'sentry/utils/localStorage'; import clamp from 'sentry/utils/number/clamp'; -import extractHtml from 'sentry/utils/replays/extractHtml'; +import extractHtmlandSelector from 'sentry/utils/replays/extractHtml'; import hydrateBreadcrumbs, { replayInitBreadcrumb, } from 'sentry/utils/replays/hydrateBreadcrumbs'; @@ -34,11 +34,12 @@ import type { SlowClickFrame, SpanFrame, VideoEvent, + WebVitalFrame, } from 'sentry/utils/replays/types'; import { BreadcrumbCategories, EventType, - getNodeId, + getNodeIds, IncrementalSource, isDeadClick, isDeadRageClick, @@ -144,16 +145,17 @@ function removeDuplicateNavCrumbs( const extractDomNodes = { shouldVisitFrame: frame => { - const nodeId = getNodeId(frame); - return nodeId !== undefined && nodeId !== -1; + const nodeIds = getNodeIds(frame); + return nodeIds.filter(nodeId => nodeId !== -1).length > 0; }, onVisitFrame: (frame, collection, replayer) => { const mirror = replayer.getMirror(); - const nodeId = getNodeId(frame); - const html = extractHtml(nodeId as number, mirror); + const nodeIds = getNodeIds(frame); + const {html, selectors} = extractHtmlandSelector((nodeIds ?? []) as number[], mirror); collection.set(frame as ReplayFrame, { frame, html, + selectors, timestamp: frame.timestampMs, }); }, @@ -576,7 +578,9 @@ export default class ReplayReader { ) ) ), - ...this._sortedSpanFrames.filter(frame => 'nodeId' in (frame.data ?? {})), + ...this._sortedSpanFrames.filter( + frame => 'nodeId' in (frame.data ?? {}) || 'nodeIds' in (frame.data ?? {}) + ), ].sort(sortFrames) ); @@ -628,7 +632,21 @@ export default class ReplayReader { getWebVitalFrames = memoize(() => { if (this._featureFlags?.includes('session-replay-web-vitals')) { - return this._sortedSpanFrames.filter(isWebVitalFrame); + // sort by largest timestamp first to easily find the last CLS in a burst + const allWebVitals = this._sortedSpanFrames.filter(isWebVitalFrame).reverse(); + let lastTimestamp = 0; + const groupedCls: WebVitalFrame[] = []; + + for (const cls of allWebVitals) { + if (cls.description === 'cumulative-layout-shift') { + if (lastTimestamp === cls.timestampMs) { + groupedCls.push(cls); + } else { + lastTimestamp = cls.timestampMs; + } + } + } + return allWebVitals.filter(frame => !groupedCls.includes(frame)).reverse(); } return []; }); diff --git a/static/app/utils/replays/types.tsx b/static/app/utils/replays/types.tsx index 466a1ef8d5e29..f1af185861be5 100644 --- a/static/app/utils/replays/types.tsx +++ b/static/app/utils/replays/types.tsx @@ -171,10 +171,12 @@ export function getFrameOpOrCategory(frame: ReplayFrame) { return val; } -export function getNodeId(frame: ReplayFrame) { +export function getNodeIds(frame: ReplayFrame) { return 'data' in frame && frame.data && 'nodeId' in frame.data - ? frame.data.nodeId - : undefined; + ? [frame.data.nodeId] + : 'data' in frame && frame.data && 'nodeIds' in frame.data + ? frame.data.nodeIds + : undefined; } export function isConsoleFrame(frame: BreadcrumbFrame): frame is ConsoleFrame { diff --git a/static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx b/static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx index f0a0a818ef1bc..f043f8d08e4e0 100644 --- a/static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx +++ b/static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx @@ -1,4 +1,4 @@ -import type {CSSProperties, MouseEvent} from 'react'; +import type {CSSProperties} from 'react'; import {useCallback} from 'react'; import classNames from 'classnames'; @@ -17,8 +17,7 @@ interface Props { onInspectorExpanded: ( index: number, path: string, - expandedState: Record, - event: MouseEvent + expandedState: Record ) => void; startTimestampMs: number; style: CSSProperties; @@ -42,7 +41,7 @@ export default function BreadcrumbRow({ const {onMouseEnter, onMouseLeave} = useCrumbHandlers(); const handleObjectInspectorExpanded = useCallback( - (path, expandedState, e) => onInspectorExpanded?.(index, path, expandedState, e), + (path, expandedState) => onInspectorExpanded?.(index, path, expandedState), [index, onInspectorExpanded] ); diff --git a/static/app/views/replays/detail/useVirtualizedInspector.tsx b/static/app/views/replays/detail/useVirtualizedInspector.tsx index 564a861c221a7..9c35613ffeca3 100644 --- a/static/app/views/replays/detail/useVirtualizedInspector.tsx +++ b/static/app/views/replays/detail/useVirtualizedInspector.tsx @@ -23,12 +23,7 @@ export default function useVirtualizedInspector({cache, listRef, expandPathsRef} return { expandPaths: expandPathsRef.current, handleDimensionChange: useCallback( - ( - index: number, - path: string, - expandedState: Record, - event: MouseEvent - ) => { + (index: number, path: string, expandedState: Record) => { const rowState = expandPathsRef.current?.get(index) || new Set(); if (expandedState[path]) { rowState.add(path); @@ -38,7 +33,6 @@ export default function useVirtualizedInspector({cache, listRef, expandPathsRef} } expandPathsRef.current?.set(index, rowState); handleDimensionChange(index); - event.stopPropagation(); }, [expandPathsRef, handleDimensionChange] ),