@progen-labs/spectra 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/assets/{index-BvBsPok5.js → index-D0Lxrmdt.js} +11 -11
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/analysis/findings.js +34 -15
- package/server/analysis/report.js +27 -2
- package/server/probe.browser.js +142 -3
- package/src/App.jsx +1 -1
- package/src/components/Culprits.jsx +2 -0
- package/src/components/ReactPanel.jsx +10 -4
- package/src/components/SpectraCulprits.jsx +39 -0
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Performance Profiler</title>
|
|
7
7
|
<link rel="icon" href="data:," />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-D0Lxrmdt.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-Da3Le5Px.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@progen-labs/spectra",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Spectra — a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -226,9 +226,28 @@ export function buildFindings(report) {
|
|
|
226
226
|
});
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
// ----- React
|
|
229
|
+
// ----- Components (React / Vue / …) -------------------------------------
|
|
230
230
|
const componentCulprit = (c, ms, note) =>
|
|
231
231
|
culprit(c.name, c.file, c.line, c.lineKind, c.abs, ms, note);
|
|
232
|
+
const fw = react.framework || 'react';
|
|
233
|
+
const CAT =
|
|
234
|
+
fw === 'vue'
|
|
235
|
+
? 'Vue'
|
|
236
|
+
: fw === 'svelte'
|
|
237
|
+
? 'Svelte'
|
|
238
|
+
: fw === 'angular'
|
|
239
|
+
? 'Angular'
|
|
240
|
+
: 'React';
|
|
241
|
+
const slowFix =
|
|
242
|
+
fw === 'vue'
|
|
243
|
+
? 'Move expensive work into a computed property, use v-memo/v-once for static subtrees, and virtualise long lists.'
|
|
244
|
+
: fw === 'react' || fw === 'next'
|
|
245
|
+
? 'Move expensive calculations into useMemo, virtualise long lists, and avoid deriving large data structures inside render.'
|
|
246
|
+
: 'Memoize expensive work, virtualise long lists, and avoid building large data structures during render.';
|
|
247
|
+
const frequentFix =
|
|
248
|
+
fw === 'vue'
|
|
249
|
+
? 'Find the reactive dependency that changes this often (a ref/store/timer), narrow it, and use v-memo to skip unchanged subtrees.'
|
|
250
|
+
: 'Find what updates this often (a timer, a store subscription, a scroll handler) and throttle it or narrow the selector/subscription.';
|
|
232
251
|
if (react.detected && react.hasTimings) {
|
|
233
252
|
react.components
|
|
234
253
|
.filter(c => c.wasted >= 5 && c.wasted / c.renders >= 0.3)
|
|
@@ -240,7 +259,7 @@ export function buildFindings(report) {
|
|
|
240
259
|
id: `wasted-${c.name}-${c.file}`,
|
|
241
260
|
severity:
|
|
242
261
|
c.wastedMs >= 30 || c.wasted >= 100 ? 'critical' : 'warning',
|
|
243
|
-
category:
|
|
262
|
+
category: CAT,
|
|
244
263
|
title: `${c.name} re-rendered ${c.renders}× — ${c.wasted} with nothing changed (${Math.round((c.wasted / c.renders) * 100)}%)`,
|
|
245
264
|
detail:
|
|
246
265
|
`${where(c.file, c.line)}. ${fmt(c.wastedMs)} spent on renders whose props, state and context were identical.` +
|
|
@@ -272,7 +291,7 @@ export function buildFindings(report) {
|
|
|
272
291
|
add({
|
|
273
292
|
id: `unstable-${c.name}-${p.prop}`,
|
|
274
293
|
severity: 'warning',
|
|
275
|
-
category:
|
|
294
|
+
category: CAT,
|
|
276
295
|
title: `${c.name} gets a new ${p.kind === 'function' ? 'function' : 'object'} for “${p.prop}” on every render (${p.count}×)`,
|
|
277
296
|
detail: `${where(c.file, c.line)}. The value is recreated with ${p.kind === 'function' ? 'identical code' : 'identical contents'} but a new identity, which defeats React.memo and re-triggers effects that depend on it. Look at where ${c.topParent || 'the parent'} passes “${p.prop}”.`,
|
|
278
297
|
file: c.file,
|
|
@@ -300,13 +319,13 @@ export function buildFindings(report) {
|
|
|
300
319
|
add({
|
|
301
320
|
id: `slow-component-${c.name}-${c.file}`,
|
|
302
321
|
severity: c.selfMs >= 300 ? 'critical' : 'warning',
|
|
303
|
-
category:
|
|
322
|
+
category: CAT,
|
|
304
323
|
title: `${c.name} is slow to render: ${fmt(c.selfMs)} total, ${fmt(c.selfMs / c.renders)} per render (${c.renders}×)`,
|
|
305
324
|
detail: `${where(c.file, c.line)}. Self time excludes its children.`,
|
|
306
325
|
file: c.file,
|
|
307
326
|
culprits: [componentCulprit(c, c.selfMs, 'slow render')],
|
|
308
327
|
impactMs: c.selfMs,
|
|
309
|
-
fix:
|
|
328
|
+
fix: slowFix,
|
|
310
329
|
});
|
|
311
330
|
});
|
|
312
331
|
react.components
|
|
@@ -321,7 +340,7 @@ export function buildFindings(report) {
|
|
|
321
340
|
add({
|
|
322
341
|
id: `frequent-${c.name}-${c.file}`,
|
|
323
342
|
severity: 'warning',
|
|
324
|
-
category:
|
|
343
|
+
category: CAT,
|
|
325
344
|
title: `${c.name} rendered ${c.renders}× (${(c.renders / (report.meta.durationMs / 1000)).toFixed(0)}/s)`,
|
|
326
345
|
detail: `${where(c.file, c.line)}. Reasons: ${
|
|
327
346
|
Object.entries(c.reasons)
|
|
@@ -332,7 +351,7 @@ export function buildFindings(report) {
|
|
|
332
351
|
file: c.file,
|
|
333
352
|
culprits: [componentCulprit(c, c.selfMs, 'renders very often')],
|
|
334
353
|
impactMs: c.selfMs,
|
|
335
|
-
fix:
|
|
354
|
+
fix: frequentFix,
|
|
336
355
|
});
|
|
337
356
|
});
|
|
338
357
|
react.slowCommits.slice(0, 3).forEach((c, i) => {
|
|
@@ -340,8 +359,8 @@ export function buildFindings(report) {
|
|
|
340
359
|
add({
|
|
341
360
|
id: `commit-${i}`,
|
|
342
361
|
severity: c.dur >= 100 ? 'critical' : 'warning',
|
|
343
|
-
category:
|
|
344
|
-
title:
|
|
362
|
+
category: CAT,
|
|
363
|
+
title: `${CAT} commit took ${fmt(c.dur)} (${c.rendered} components rendered, ${c.wasted} wasted)`,
|
|
345
364
|
detail: `At ${(c.tMs / 1000).toFixed(2)}s.`,
|
|
346
365
|
culprits: react.components
|
|
347
366
|
.slice(0, 3)
|
|
@@ -356,20 +375,20 @@ export function buildFindings(report) {
|
|
|
356
375
|
add({
|
|
357
376
|
id: 'react-prod',
|
|
358
377
|
severity: 'info',
|
|
359
|
-
category:
|
|
360
|
-
title:
|
|
378
|
+
category: CAT,
|
|
379
|
+
title: `${CAT} is a production build: render timings are unavailable`,
|
|
361
380
|
detail:
|
|
362
381
|
'Render counts and reasons are still collected where possible, but durations need a development or profiling build.',
|
|
363
|
-
fix: 'Profile against the dev server (npm run
|
|
382
|
+
fix: 'Profile against the dev server (npm run dev / start).',
|
|
364
383
|
});
|
|
365
384
|
} else if (!react.detected) {
|
|
366
385
|
add({
|
|
367
386
|
id: 'react-missing',
|
|
368
387
|
severity: 'info',
|
|
369
|
-
category:
|
|
370
|
-
title:
|
|
388
|
+
category: CAT,
|
|
389
|
+
title: `No ${CAT === 'React' ? 'React renderer' : CAT + ' app'} was seen on this page`,
|
|
371
390
|
detail:
|
|
372
|
-
'The probe must be installed before
|
|
391
|
+
'The probe must be installed before the framework loads (use “Reload & record”), and component-level timings need a development build.',
|
|
373
392
|
fix: 'Click “Record page load”, or reload the tab from this tool.',
|
|
374
393
|
});
|
|
375
394
|
}
|
|
@@ -19,8 +19,21 @@ import {
|
|
|
19
19
|
findMainThread,
|
|
20
20
|
} from './trace.js';
|
|
21
21
|
import { packageOf } from './sources.js';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { FRONTEND_ROOT } from '../config.js';
|
|
22
24
|
|
|
23
25
|
const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
|
|
26
|
+
|
|
27
|
+
/** Turn an absolute component file (e.g. Vue SFC __file) into a project path. */
|
|
28
|
+
function toProjectRelative(abs) {
|
|
29
|
+
try {
|
|
30
|
+
if (!abs || !path.isAbsolute(abs)) return abs || '';
|
|
31
|
+
const rel = path.relative(FRONTEND_ROOT, abs);
|
|
32
|
+
return rel.startsWith('..') ? abs : rel;
|
|
33
|
+
} catch {
|
|
34
|
+
return abs || '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
24
37
|
const ms = us => us / 1000;
|
|
25
38
|
|
|
26
39
|
function newFile(path, vendor, pkg) {
|
|
@@ -635,6 +648,7 @@ function summarizeReact(
|
|
|
635
648
|
) {
|
|
636
649
|
const empty = {
|
|
637
650
|
detected: false,
|
|
651
|
+
framework: probe?.info?.framework || '',
|
|
638
652
|
hasTimings: false,
|
|
639
653
|
version: '',
|
|
640
654
|
components: [],
|
|
@@ -642,12 +656,22 @@ function summarizeReact(
|
|
|
642
656
|
slowCommits: [],
|
|
643
657
|
commitSeries: [],
|
|
644
658
|
};
|
|
645
|
-
if (!probe || !probe.info?.
|
|
659
|
+
if (!probe || !probe.info?.detected) return empty;
|
|
646
660
|
const originMs = probe.since || 0;
|
|
647
661
|
const components = probe.components
|
|
648
662
|
.map(c => {
|
|
649
663
|
let loc;
|
|
650
|
-
if (
|
|
664
|
+
if (c.file) {
|
|
665
|
+
// Adapters that know the component's source file directly (e.g. Vue SFCs).
|
|
666
|
+
const rel = toProjectRelative(c.file);
|
|
667
|
+
loc = {
|
|
668
|
+
file: rel,
|
|
669
|
+
line: c.line || null,
|
|
670
|
+
vendor: /(^|\/)node_modules\//.test(rel),
|
|
671
|
+
pkg: null,
|
|
672
|
+
exact: false,
|
|
673
|
+
};
|
|
674
|
+
} else if (locations.has(c.id)) {
|
|
651
675
|
const l = locations.get(c.id);
|
|
652
676
|
const script = resolver.scripts.get(String(l.scriptId));
|
|
653
677
|
loc = resolver.resolve(
|
|
@@ -722,6 +746,7 @@ function summarizeReact(
|
|
|
722
746
|
const sorted = [...commits].sort((a, b) => b.dur - a.dur);
|
|
723
747
|
return {
|
|
724
748
|
detected: true,
|
|
749
|
+
framework: probe.info.framework || 'react',
|
|
725
750
|
hasTimings: probe.info.timings,
|
|
726
751
|
version: probe.info.version,
|
|
727
752
|
components: components.slice(0, 600),
|
package/server/probe.browser.js
CHANGED
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
if (window.__PERF_PROBE__) return;
|
|
16
16
|
// Runtime hint injected by the profiler: { framework, bundler, isSSR }
|
|
17
17
|
var SPECTRA_FRAMEWORK = typeof window.__SPECTRA_FRAMEWORK__ !== 'undefined' ? window.__SPECTRA_FRAMEWORK__ : null;
|
|
18
|
-
var WANT_REACT =
|
|
18
|
+
var WANT_REACT =
|
|
19
|
+
!SPECTRA_FRAMEWORK ||
|
|
20
|
+
SPECTRA_FRAMEWORK.framework === 'react' ||
|
|
21
|
+
SPECTRA_FRAMEWORK.framework === 'next' ||
|
|
22
|
+
SPECTRA_FRAMEWORK.framework === 'unknown';
|
|
19
23
|
|
|
20
24
|
var PERFORMED_WORK = 1;
|
|
21
25
|
var TAG_FUNCTION = 0;
|
|
@@ -521,9 +525,14 @@
|
|
|
521
525
|
};
|
|
522
526
|
|
|
523
527
|
probe.info = function () {
|
|
528
|
+
var fw =
|
|
529
|
+
probe.frameworkName ||
|
|
530
|
+
(probe.renderers > 0 ? 'react' : (SPECTRA_FRAMEWORK && SPECTRA_FRAMEWORK.framework) || '');
|
|
524
531
|
return {
|
|
525
532
|
react: probe.renderers > 0,
|
|
526
|
-
|
|
533
|
+
framework: fw,
|
|
534
|
+
detected: probe.renderers > 0 || !!probe.frameworkDetected,
|
|
535
|
+
version: probe.reactVersion || probe.frameworkVersion || '',
|
|
527
536
|
active: probe.active,
|
|
528
537
|
timings: probe.sawTimings,
|
|
529
538
|
};
|
|
@@ -584,6 +593,134 @@
|
|
|
584
593
|
// Fail silently: probe should still collect vitals even if hooking fails.
|
|
585
594
|
}
|
|
586
595
|
|
|
596
|
+
// ------------------------------------------------ Vue adapter
|
|
597
|
+
// Uses Vue's devtools global hook (dev builds). component:added / :updated
|
|
598
|
+
// give per-component mount/update counts and names, and perf:start / perf:end
|
|
599
|
+
// give real per-component render time. Component source files come straight
|
|
600
|
+
// from the SFC compiler (type.__file), so no source-map step is needed.
|
|
601
|
+
function installVueAdapter() {
|
|
602
|
+
var vueIds = new Map();
|
|
603
|
+
var nextId = 0;
|
|
604
|
+
var perfStart = {};
|
|
605
|
+
|
|
606
|
+
function getStat(instance) {
|
|
607
|
+
if (!instance || !instance.type) return null;
|
|
608
|
+
var type = instance.type;
|
|
609
|
+
var id = vueIds.get(type);
|
|
610
|
+
if (id === undefined) {
|
|
611
|
+
id = nextId++;
|
|
612
|
+
vueIds.set(type, id);
|
|
613
|
+
}
|
|
614
|
+
var stat = probe.stats[id];
|
|
615
|
+
if (!stat) {
|
|
616
|
+
stat = probe.stats[id] = {
|
|
617
|
+
id: id,
|
|
618
|
+
name: type.__name || type.name || type.__file && baseName(type.__file) || 'Anonymous',
|
|
619
|
+
kind: 'vue',
|
|
620
|
+
renders: 0,
|
|
621
|
+
mounts: 0,
|
|
622
|
+
wasted: 0,
|
|
623
|
+
selfMs: 0,
|
|
624
|
+
totalMs: 0,
|
|
625
|
+
maxMs: 0,
|
|
626
|
+
reasons: { props: 0, state: 0, hooks: 0, context: 0, parent: 0 },
|
|
627
|
+
props: {},
|
|
628
|
+
parents: {},
|
|
629
|
+
file: type.__file || '',
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
return stat;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function baseName(p) {
|
|
636
|
+
var parts = String(p).split(/[\\/]/);
|
|
637
|
+
return parts[parts.length - 1].replace(/\.\w+$/, '');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function recordParent(stat, instance) {
|
|
641
|
+
var p = instance && instance.parent;
|
|
642
|
+
var name = p && p.type && (p.type.__name || p.type.name);
|
|
643
|
+
if (name) stat.parents[name] = (stat.parents[name] || 0) + 1;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function bumpCommit(dur) {
|
|
647
|
+
probe.commits.push({ t: performance.now(), dur: dur || 0, rendered: 1, wasted: 0 });
|
|
648
|
+
probe.commitCount = (probe.commitCount || 0) + 1;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function handle(evt, args) {
|
|
652
|
+
if (evt === 'app:init') {
|
|
653
|
+
probe.frameworkName = 'vue';
|
|
654
|
+
probe.frameworkDetected = true;
|
|
655
|
+
probe.frameworkVersion = args[1] || '';
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (!probe.active) return;
|
|
659
|
+
if (evt === 'component:added') {
|
|
660
|
+
var stat = getStat(args[3]);
|
|
661
|
+
if (stat) {
|
|
662
|
+
stat.mounts++;
|
|
663
|
+
stat.renders++;
|
|
664
|
+
recordParent(stat, args[3]);
|
|
665
|
+
bumpCommit();
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (evt === 'component:updated') {
|
|
670
|
+
var s2 = getStat(args[3]);
|
|
671
|
+
if (s2) {
|
|
672
|
+
s2.renders++;
|
|
673
|
+
recordParent(s2, args[3]);
|
|
674
|
+
bumpCommit();
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (evt === 'perf:start' || evt === 'perf:end') {
|
|
679
|
+
var uid = args[1];
|
|
680
|
+
var inst = args[2];
|
|
681
|
+
var phase = args[3];
|
|
682
|
+
var time = args[4];
|
|
683
|
+
var key = uid + ':' + phase;
|
|
684
|
+
if (evt === 'perf:start') {
|
|
685
|
+
perfStart[key] = time;
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
var start = perfStart[key];
|
|
689
|
+
if (start === undefined) return;
|
|
690
|
+
delete perfStart[key];
|
|
691
|
+
var dur = Math.max(0, time - start);
|
|
692
|
+
var st = getStat(inst);
|
|
693
|
+
if (!st) return;
|
|
694
|
+
if (phase === 'render') {
|
|
695
|
+
st.selfMs += dur;
|
|
696
|
+
st.totalMs += dur;
|
|
697
|
+
if (dur > st.maxMs) st.maxMs = dur;
|
|
698
|
+
probe.sawTimings = true;
|
|
699
|
+
} else if (phase === 'patch') {
|
|
700
|
+
st.totalMs += dur;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
var existing = window.__VUE_DEVTOOLS_GLOBAL_HOOK__;
|
|
706
|
+
var hook = existing || {};
|
|
707
|
+
hook.enabled = true;
|
|
708
|
+
hook.apps = hook.apps || [];
|
|
709
|
+
hook.store = hook.store || {};
|
|
710
|
+
var prevEmit = typeof hook.emit === 'function' ? hook.emit.bind(hook) : null;
|
|
711
|
+
hook.on = hook.on || function () {};
|
|
712
|
+
hook.once = hook.once || function () {};
|
|
713
|
+
hook.off = hook.off || function () {};
|
|
714
|
+
hook.emit = function (evt) {
|
|
715
|
+
try {
|
|
716
|
+
handle(evt, Array.prototype.slice.call(arguments, 1));
|
|
717
|
+
} catch (e) {}
|
|
718
|
+
if (prevEmit) try { prevEmit.apply(null, arguments); } catch (e) {}
|
|
719
|
+
};
|
|
720
|
+
window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = hook;
|
|
721
|
+
probe.frameworkName = 'vue';
|
|
722
|
+
}
|
|
723
|
+
|
|
587
724
|
// ------------------------------------------------ Framework fallbacks
|
|
588
725
|
// Provide lightweight probes for Vue, Svelte and Angular when React is not detected.
|
|
589
726
|
function installFrameworkProbes() {
|
|
@@ -683,7 +820,9 @@
|
|
|
683
820
|
}
|
|
684
821
|
|
|
685
822
|
try {
|
|
686
|
-
|
|
823
|
+
var fwHint = SPECTRA_FRAMEWORK && SPECTRA_FRAMEWORK.framework;
|
|
824
|
+
if (fwHint === 'vue') installVueAdapter();
|
|
825
|
+
else if (!WANT_REACT) installFrameworkProbes(); // svelte/angular: heuristic for now
|
|
687
826
|
} catch (e) {}
|
|
688
827
|
installObservers();
|
|
689
828
|
})();
|
package/src/App.jsx
CHANGED
|
@@ -23,7 +23,7 @@ const TABS = [
|
|
|
23
23
|
{ id: 'page', label: 'Page map' },
|
|
24
24
|
{ id: 'files', label: 'File tree' },
|
|
25
25
|
{ id: 'calls', label: 'Call tree' },
|
|
26
|
-
{ id: 'react', label: '
|
|
26
|
+
{ id: 'react', label: 'Components' },
|
|
27
27
|
{ id: 'tasks', label: 'Long tasks' },
|
|
28
28
|
{ id: 'network', label: 'Network & memory' },
|
|
29
29
|
{ id: 'evaluation', label: 'Golden paths' },
|
|
@@ -5,6 +5,7 @@ import CulpritsHero from './CulpritsHero.jsx';
|
|
|
5
5
|
import FindingsList from './FindingsList.jsx';
|
|
6
6
|
import TimeBreakdown from './TimeBreakdown.jsx';
|
|
7
7
|
import Vitals from './Vitals.jsx';
|
|
8
|
+
import SpectraCulprits from './SpectraCulprits.jsx';
|
|
8
9
|
|
|
9
10
|
function HotFunctions({ report, hideVendor, onOpenFile }) {
|
|
10
11
|
const rows = (report.hotFunctions || [])
|
|
@@ -73,6 +74,7 @@ export default function Culprits({ report, hideVendor, onOpenFile }) {
|
|
|
73
74
|
onJump={id => setFocus(prev => ({ id, tick: (prev?.tick || 0) + 1 }))}
|
|
74
75
|
/>
|
|
75
76
|
<Vitals report={report} />
|
|
77
|
+
<SpectraCulprits report={report} onOpenFile={onOpenFile} />
|
|
76
78
|
<section>
|
|
77
79
|
<h2>Where main-thread time went</h2>
|
|
78
80
|
<TimeBreakdown totals={report.totals} />
|
|
@@ -101,14 +101,18 @@ export default function ReactPanel({ report, hideVendor, onOpenFile }) {
|
|
|
101
101
|
);
|
|
102
102
|
}, [react.components, sort, hideVendor]);
|
|
103
103
|
|
|
104
|
+
const fwLabel =
|
|
105
|
+
{ vue: 'Vue', svelte: 'Svelte', angular: 'Angular', next: 'React' }[
|
|
106
|
+
react.framework
|
|
107
|
+
] || 'React';
|
|
104
108
|
if (!react.detected) {
|
|
105
109
|
return (
|
|
106
110
|
<div className="empty">
|
|
107
|
-
<h3>No
|
|
111
|
+
<h3>No component framework was detected</h3>
|
|
108
112
|
<p>
|
|
109
|
-
The probe has to be installed before
|
|
113
|
+
The probe has to be installed before the framework loads. Use{' '}
|
|
110
114
|
<b>Record page load</b>, or reload the tab from this tool and record
|
|
111
|
-
again.
|
|
115
|
+
again. Component timings need a development build.
|
|
112
116
|
</p>
|
|
113
117
|
</div>
|
|
114
118
|
);
|
|
@@ -116,7 +120,9 @@ export default function ReactPanel({ report, hideVendor, onOpenFile }) {
|
|
|
116
120
|
return (
|
|
117
121
|
<div className="react-panel">
|
|
118
122
|
<div className="summary-row">
|
|
119
|
-
<span>
|
|
123
|
+
<span>
|
|
124
|
+
{fwLabel} {react.version}
|
|
125
|
+
</span>
|
|
120
126
|
<span>{fmtNumber(react.commits.count)} commits</span>
|
|
121
127
|
<span>total render time {fmtMs(react.commits.totalMs)}</span>
|
|
122
128
|
<span>slowest commit {fmtMs(react.commits.maxMs)}</span>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import CodeRef from './CodeRef.jsx';
|
|
3
|
+
|
|
4
|
+
export default function SpectraCulprits({ report, onOpenFile }) {
|
|
5
|
+
const culprits = report.spectra?.culprits || [];
|
|
6
|
+
if (!culprits || !culprits.length) return null;
|
|
7
|
+
return (
|
|
8
|
+
<section>
|
|
9
|
+
<h2>Spectra SDK Culprits</h2>
|
|
10
|
+
<p className="muted">Suggestions from the embedded Spectra core SDK.</p>
|
|
11
|
+
<table className="data spectra-culprits">
|
|
12
|
+
<thead>
|
|
13
|
+
<tr>
|
|
14
|
+
<th>#</th>
|
|
15
|
+
<th>Component</th>
|
|
16
|
+
<th>File</th>
|
|
17
|
+
<th>Time</th>
|
|
18
|
+
</tr>
|
|
19
|
+
</thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
{culprits.map((c, i) => (
|
|
22
|
+
<tr key={`${c.filePath}|${c.componentName}|${i}`}>
|
|
23
|
+
<td className="num">{i + 1}</td>
|
|
24
|
+
<td>{c.componentName}</td>
|
|
25
|
+
<td>
|
|
26
|
+
<CodeRef
|
|
27
|
+
file={c.filePath}
|
|
28
|
+
line={c.lineNumber}
|
|
29
|
+
onOpenFile={onOpenFile}
|
|
30
|
+
/>
|
|
31
|
+
</td>
|
|
32
|
+
<td className="num">{c.durationMs ? `${Math.round(c.durationMs)}ms` : '-'}</td>
|
|
33
|
+
</tr>
|
|
34
|
+
))}
|
|
35
|
+
</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
</section>
|
|
38
|
+
);
|
|
39
|
+
}
|