1
- import type { CSSProperties , MouseEvent } from 'react' ;
1
+ import type { CSSProperties , ReactNode } from 'react' ;
2
2
import { isValidElement , memo , useCallback } from 'react' ;
3
3
import styled from '@emotion/styled' ;
4
4
import beautify from 'js-beautify' ;
5
5
6
6
import ProjectAvatar from 'sentry/components/avatar/projectAvatar' ;
7
+ import { Button } from 'sentry/components/button' ;
7
8
import { CodeSnippet } from 'sentry/components/codeSnippet' ;
8
9
import { Flex } from 'sentry/components/container/flex' ;
9
10
import ErrorBoundary from 'sentry/components/errorBoundary' ;
@@ -13,6 +14,7 @@ import PanelItem from 'sentry/components/panels/panelItem';
13
14
import { OpenReplayComparisonButton } from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton' ;
14
15
import { useReplayContext } from 'sentry/components/replays/replayContext' ;
15
16
import { useReplayGroupContext } from 'sentry/components/replays/replayGroupContext' ;
17
+ import StructuredEventData from 'sentry/components/structuredEventData' ;
16
18
import Timeline from 'sentry/components/timeline' ;
17
19
import { useHasNewTimelineUI } from 'sentry/components/timeline/utils' ;
18
20
import { Tooltip } from 'sentry/components/tooltip' ;
@@ -21,37 +23,37 @@ import {space} from 'sentry/styles/space';
21
23
import type { Extraction } from 'sentry/utils/replays/extractHtml' ;
22
24
import { getReplayDiffOffsetsFromFrame } from 'sentry/utils/replays/getDiffTimestamps' ;
23
25
import getFrameDetails from 'sentry/utils/replays/getFrameDetails' ;
26
+ import useExtractDomNodes from 'sentry/utils/replays/hooks/useExtractDomNodes' ;
24
27
import type ReplayReader from 'sentry/utils/replays/replayReader' ;
25
28
import type {
26
29
ErrorFrame ,
27
30
FeedbackFrame ,
28
31
HydrationErrorFrame ,
29
32
ReplayFrame ,
33
+ WebVitalFrame ,
30
34
} from 'sentry/utils/replays/types' ;
31
35
import {
32
36
isBreadcrumbFrame ,
33
37
isErrorFrame ,
34
38
isFeedbackFrame ,
35
39
isHydrationErrorFrame ,
40
+ isSpanFrame ,
41
+ isWebVitalFrame ,
36
42
} from 'sentry/utils/replays/types' ;
37
43
import type { Color } from 'sentry/utils/theme' ;
38
44
import useOrganization from 'sentry/utils/useOrganization' ;
39
45
import useProjectFromSlug from 'sentry/utils/useProjectFromSlug' ;
40
46
import IconWrapper from 'sentry/views/replays/detail/iconWrapper' ;
41
47
import TimestampButton from 'sentry/views/replays/detail/timestampButton' ;
42
48
43
- type MouseCallback = ( frame : ReplayFrame , e : React . MouseEvent < HTMLElement > ) => void ;
49
+ type MouseCallback = ( frame : ReplayFrame , nodeId ?: number ) => void ;
44
50
45
51
const FRAMES_WITH_BUTTONS = [ 'replay.hydrate-error' ] ;
46
52
47
53
interface Props {
48
54
frame : ReplayFrame ;
49
55
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 ;
55
57
onMouseEnter : MouseCallback ;
56
58
onMouseLeave : MouseCallback ;
57
59
startTimestampMs : number ;
@@ -105,15 +107,31 @@ function BreadcrumbItem({
105
107
) : null ;
106
108
} , [ frame , replay ] ) ;
107
109
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
+ />
115
120
) : 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 ] ) ;
117
135
118
136
const renderIssueLink = useCallback ( ( ) => {
119
137
return isErrorFrame ( frame ) || isFeedbackFrame ( frame ) ? (
@@ -143,13 +161,17 @@ function BreadcrumbItem({
143
161
data-is-error-frame = { isErrorFrame ( frame ) }
144
162
style = { style }
145
163
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 ) }
149
170
>
150
171
< ErrorBoundary mini >
151
172
{ renderDescription ( ) }
152
173
{ renderComparisonButton ( ) }
174
+ { renderWebVital ( ) }
153
175
{ renderCodeSnippet ( ) }
154
176
{ renderIssueLink ( ) }
155
177
</ ErrorBoundary >
@@ -160,9 +182,12 @@ function BreadcrumbItem({
160
182
< CrumbItem
161
183
data-is-error-frame = { isErrorFrame ( frame ) }
162
184
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 ) }
166
191
style = { style }
167
192
className = { className }
168
193
>
@@ -184,6 +209,7 @@ function BreadcrumbItem({
184
209
{ renderDescription ( ) }
185
210
</ Flex >
186
211
{ renderComparisonButton ( ) }
212
+ { renderWebVital ( ) }
187
213
{ renderCodeSnippet ( ) }
188
214
{ renderIssueLink ( ) }
189
215
</ CrumbDetails >
@@ -192,6 +218,100 @@ function BreadcrumbItem({
192
218
) ;
193
219
}
194
220
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
+
195
315
function CrumbHydrationButton ( {
196
316
replay,
197
317
frame,
@@ -381,4 +501,27 @@ const CodeContainer = styled('div')`
381
501
overflow: auto;
382
502
` ;
383
503
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
+
384
527
export default memo ( BreadcrumbItem ) ;
0 commit comments