1- import type { CSSProperties , MouseEvent } from 'react' ;
1+ import type { CSSProperties , ReactNode } from 'react' ;
22import { isValidElement , memo , useCallback } from 'react' ;
33import styled from '@emotion/styled' ;
44import beautify from 'js-beautify' ;
55
66import ProjectAvatar from 'sentry/components/avatar/projectAvatar' ;
7+ import { Button } from 'sentry/components/button' ;
78import { CodeSnippet } from 'sentry/components/codeSnippet' ;
89import { Flex } from 'sentry/components/container/flex' ;
910import ErrorBoundary from 'sentry/components/errorBoundary' ;
@@ -13,6 +14,7 @@ import PanelItem from 'sentry/components/panels/panelItem';
1314import { OpenReplayComparisonButton } from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton' ;
1415import { useReplayContext } from 'sentry/components/replays/replayContext' ;
1516import { useReplayGroupContext } from 'sentry/components/replays/replayGroupContext' ;
17+ import StructuredEventData from 'sentry/components/structuredEventData' ;
1618import Timeline from 'sentry/components/timeline' ;
1719import { useHasNewTimelineUI } from 'sentry/components/timeline/utils' ;
1820import { Tooltip } from 'sentry/components/tooltip' ;
@@ -21,37 +23,37 @@ import {space} from 'sentry/styles/space';
2123import type { Extraction } from 'sentry/utils/replays/extractHtml' ;
2224import { getReplayDiffOffsetsFromFrame } from 'sentry/utils/replays/getDiffTimestamps' ;
2325import getFrameDetails from 'sentry/utils/replays/getFrameDetails' ;
26+ import useExtractDomNodes from 'sentry/utils/replays/hooks/useExtractDomNodes' ;
2427import type ReplayReader from 'sentry/utils/replays/replayReader' ;
2528import type {
2629 ErrorFrame ,
2730 FeedbackFrame ,
2831 HydrationErrorFrame ,
2932 ReplayFrame ,
33+ WebVitalFrame ,
3034} from 'sentry/utils/replays/types' ;
3135import {
3236 isBreadcrumbFrame ,
3337 isErrorFrame ,
3438 isFeedbackFrame ,
3539 isHydrationErrorFrame ,
40+ isSpanFrame ,
41+ isWebVitalFrame ,
3642} from 'sentry/utils/replays/types' ;
3743import type { Color } from 'sentry/utils/theme' ;
3844import useOrganization from 'sentry/utils/useOrganization' ;
3945import useProjectFromSlug from 'sentry/utils/useProjectFromSlug' ;
4046import IconWrapper from 'sentry/views/replays/detail/iconWrapper' ;
4147import TimestampButton from 'sentry/views/replays/detail/timestampButton' ;
4248
43- type MouseCallback = ( frame : ReplayFrame , e : React . MouseEvent < HTMLElement > ) => void ;
49+ type MouseCallback = ( frame : ReplayFrame , nodeId ?: number ) => void ;
4450
4551const FRAMES_WITH_BUTTONS = [ 'replay.hydrate-error' ] ;
4652
4753interface Props {
4854 frame : ReplayFrame ;
4955 onClick : null | MouseCallback ;
50- onInspectorExpanded : (
51- path : string ,
52- expandedState : Record < string , boolean > ,
53- event : MouseEvent < HTMLDivElement >
54- ) => void ;
56+ onInspectorExpanded : ( path : string , expandedState : Record < string , boolean > ) => void ;
5557 onMouseEnter : MouseCallback ;
5658 onMouseLeave : MouseCallback ;
5759 startTimestampMs : number ;
@@ -105,15 +107,31 @@ function BreadcrumbItem({
105107 ) : null ;
106108 } , [ frame , replay ] ) ;
107109
108- const renderCodeSnippet = useCallback ( ( ) => {
109- return extraction ?. html ? (
110- < CodeContainer >
111- < CodeSnippet language = "html" hideCopyButton >
112- { beautify . html ( extraction ?. html , { indent_size : 2 } ) }
113- </ CodeSnippet >
114- </ CodeContainer >
110+ const renderWebVital = useCallback ( ( ) => {
111+ return isSpanFrame ( frame ) && isWebVitalFrame ( frame ) ? (
112+ < WebVitalData
113+ replay = { replay }
114+ frame = { frame }
115+ expandPaths = { expandPaths }
116+ onInspectorExpanded = { onInspectorExpanded }
117+ onMouseEnter = { onMouseEnter }
118+ onMouseLeave = { onMouseLeave }
119+ />
115120 ) : null ;
116- } , [ extraction ?. html ] ) ;
121+ } , [ expandPaths , frame , onInspectorExpanded , onMouseEnter , onMouseLeave , replay ] ) ;
122+
123+ const renderCodeSnippet = useCallback ( ( ) => {
124+ return (
125+ ( ! isSpanFrame ( frame ) || ! isWebVitalFrame ( frame ) ) &&
126+ extraction ?. html ?. map ( html => (
127+ < CodeContainer key = { html } >
128+ < CodeSnippet language = "html" hideCopyButton >
129+ { beautify . html ( html , { indent_size : 2 } ) }
130+ </ CodeSnippet >
131+ </ CodeContainer >
132+ ) )
133+ ) ;
134+ } , [ extraction ?. html , frame ] ) ;
117135
118136 const renderIssueLink = useCallback ( ( ) => {
119137 return isErrorFrame ( frame ) || isFeedbackFrame ( frame ) ? (
@@ -143,13 +161,17 @@ function BreadcrumbItem({
143161 data-is-error-frame = { isErrorFrame ( frame ) }
144162 style = { style }
145163 className = { className }
146- onClick = { e => onClick ?.( frame , e ) }
147- onMouseEnter = { e => onMouseEnter ( frame , e ) }
148- onMouseLeave = { e => onMouseLeave ( frame , e ) }
164+ onClick = { event => {
165+ event . stopPropagation ( ) ;
166+ onClick ?.( frame ) ;
167+ } }
168+ onMouseEnter = { ( ) => onMouseEnter ( frame ) }
169+ onMouseLeave = { ( ) => onMouseLeave ( frame ) }
149170 >
150171 < ErrorBoundary mini >
151172 { renderDescription ( ) }
152173 { renderComparisonButton ( ) }
174+ { renderWebVital ( ) }
153175 { renderCodeSnippet ( ) }
154176 { renderIssueLink ( ) }
155177 </ ErrorBoundary >
@@ -160,9 +182,12 @@ function BreadcrumbItem({
160182 < CrumbItem
161183 data-is-error-frame = { isErrorFrame ( frame ) }
162184 as = { onClick && ! forceSpan ? 'button' : 'span' }
163- onClick = { e => onClick ?.( frame , e ) }
164- onMouseEnter = { e => onMouseEnter ( frame , e ) }
165- onMouseLeave = { e => onMouseLeave ( frame , e ) }
185+ onClick = { event => {
186+ event . stopPropagation ( ) ;
187+ onClick ?.( frame ) ;
188+ } }
189+ onMouseEnter = { ( ) => onMouseEnter ( frame ) }
190+ onMouseLeave = { ( ) => onMouseLeave ( frame ) }
166191 style = { style }
167192 className = { className }
168193 >
@@ -184,6 +209,7 @@ function BreadcrumbItem({
184209 { renderDescription ( ) }
185210 </ Flex >
186211 { renderComparisonButton ( ) }
212+ { renderWebVital ( ) }
187213 { renderCodeSnippet ( ) }
188214 { renderIssueLink ( ) }
189215 </ CrumbDetails >
@@ -192,6 +218,100 @@ function BreadcrumbItem({
192218 ) ;
193219}
194220
221+ function WebVitalData ( {
222+ replay,
223+ frame,
224+ expandPaths,
225+ onInspectorExpanded,
226+ onMouseEnter,
227+ onMouseLeave,
228+ } : {
229+ expandPaths : string [ ] | undefined ;
230+ frame : WebVitalFrame ;
231+ onInspectorExpanded : ( path : string , expandedState : Record < string , boolean > ) => void ;
232+ onMouseEnter : MouseCallback ;
233+ onMouseLeave : MouseCallback ;
234+ replay : ReplayReader | null ;
235+ } ) {
236+ const { data : frameToExtraction } = useExtractDomNodes ( { replay} ) ;
237+ const selectors = frameToExtraction ?. get ( frame ) ?. selectors ;
238+
239+ const webVitalData = { value : frame . data . value } ;
240+ if (
241+ frame . description === 'cumulative-layout-shift' &&
242+ frame . data . attributions &&
243+ selectors
244+ ) {
245+ const layoutShifts : { [ x : string ] : ReactNode [ ] } [ ] = [ ] ;
246+ for ( const attr of frame . data . attributions ) {
247+ const elements : ReactNode [ ] = [ ] ;
248+ if ( 'nodeIds' in attr && Array . isArray ( attr . nodeIds ) ) {
249+ attr . nodeIds . forEach ( nodeId => {
250+ selectors . get ( nodeId )
251+ ? elements . push (
252+ < span
253+ key = { nodeId }
254+ onMouseEnter = { ( ) => onMouseEnter ( frame , nodeId ) }
255+ onMouseLeave = { ( ) => onMouseLeave ( frame , nodeId ) }
256+ >
257+ < ValueObjectKey > { t ( 'element' ) } </ ValueObjectKey >
258+ < span > { ': ' } </ span >
259+ < span >
260+ < SelectorButton > { selectors . get ( nodeId ) } </ SelectorButton >
261+ </ span >
262+ </ span >
263+ )
264+ : null ;
265+ } ) ;
266+ }
267+ // if we can't find the elements associated with the layout shift, we still show the score with element: unknown
268+ if ( ! elements . length ) {
269+ elements . push (
270+ < span >
271+ < ValueObjectKey > { t ( 'element' ) } </ ValueObjectKey >
272+ < span > { ': ' } </ span >
273+ < ValueNull > { t ( 'unknown' ) } </ ValueNull >
274+ </ span >
275+ ) ;
276+ }
277+ layoutShifts . push ( { [ `score ${ attr . value } ` ] : elements } ) ;
278+ }
279+ if ( layoutShifts . length ) {
280+ webVitalData [ 'Layout shifts' ] = layoutShifts ;
281+ }
282+ } else if ( selectors ?. size ) {
283+ selectors . forEach ( ( key , value ) => {
284+ webVitalData [ key ] = (
285+ < span
286+ key = { key }
287+ onMouseEnter = { ( ) => onMouseEnter ( frame , value ) }
288+ onMouseLeave = { ( ) => onMouseLeave ( frame , value ) }
289+ >
290+ < ValueObjectKey > { t ( 'element' ) } </ ValueObjectKey >
291+ < span > { ': ' } </ span >
292+ < SelectorButton size = "zero" borderless >
293+ { key }
294+ </ SelectorButton >
295+ </ span >
296+ ) ;
297+ } ) ;
298+ }
299+
300+ return (
301+ < StructuredEventData
302+ initialExpandedPaths = { expandPaths ?? [ ] }
303+ onToggleExpand = { ( expandedPaths , path ) => {
304+ onInspectorExpanded (
305+ path ,
306+ Object . fromEntries ( expandedPaths . map ( item => [ item , true ] ) )
307+ ) ;
308+ } }
309+ data = { webVitalData }
310+ withAnnotatedText
311+ />
312+ ) ;
313+ }
314+
195315function CrumbHydrationButton ( {
196316 replay,
197317 frame,
@@ -381,4 +501,27 @@ const CodeContainer = styled('div')`
381501 overflow: auto;
382502` ;
383503
504+ const ValueObjectKey = styled ( 'span' ) `
505+ color: var(--prism-keyword);
506+ ` ;
507+
508+ const ValueNull = styled ( 'span' ) `
509+ font-weight: ${ p => p . theme . fontWeightBold } ;
510+ color: var(--prism-property);
511+ ` ;
512+
513+ const SelectorButton = styled ( Button ) `
514+ background: none;
515+ border: none;
516+ padding: 0 2px;
517+ border-radius: 2px;
518+ font-weight: ${ p => p . theme . fontWeightNormal } ;
519+ box-shadow: none;
520+ font-size: ${ p => p . theme . fontSizeSmall } ;
521+ color: ${ p => p . theme . subText } ;
522+ margin: 0 ${ space ( 0.5 ) } ;
523+ height: auto;
524+ min-height: auto;
525+ ` ;
526+
384527export default memo ( BreadcrumbItem ) ;
0 commit comments