@progen-labs/spectra 0.4.1 → 0.5.0

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.
@@ -1,3 +1,4 @@
1
+ import { reportFramework } from '../framework.js';
1
2
  import { useMemo, useState } from 'react';
2
3
  import { fmtMs, fmtNumber } from '../format.js';
3
4
 
@@ -34,7 +35,7 @@ function buildPins(report, hideVendor) {
34
35
  return pool
35
36
  .sort((a, b) => b.c.selfMs + b.c.wastedMs - (a.c.selfMs + a.c.wastedMs))
36
37
  .slice(0, MAX_PINS)
37
- .map((p, i) => ({ ...p, key: `${p.c.id}`, n: i + 1 }));
38
+ .map((p, i) => ({ ...p, fw: report.react?.framework || 'react', key: `${p.c.id}`, n: i + 1 }));
38
39
  }
39
40
 
40
41
  function severityOf(c, maxSelf) {
@@ -48,6 +49,14 @@ function topReason(c) {
48
49
  return n ? `${n}× ${REASON_LABELS[key]}` : null;
49
50
  }
50
51
 
52
+ // what to do about renders that changed nothing, in the framework's own terms
53
+ function wastedFix(fw) {
54
+ if (fw === 'vue') return <>pass stable props (no new objects or functions inline), or use <code>v-memo</code> for parts that rarely change.</>;
55
+ if (fw === 'angular') return <>use <code>ChangeDetectionStrategy.OnPush</code> and pass new references only when data changes.</>;
56
+ if (fw === 'react' || fw === 'next') return <>wrap in <code>React.memo</code> or stop the parent re-rendering.</>;
57
+ return <>stop the parent re-rendering it when nothing changed.</>;
58
+ }
59
+
51
60
  function Details({ pin }) {
52
61
  const { c } = pin;
53
62
  const reason = topReason(c);
@@ -88,8 +97,7 @@ function Details({ pin }) {
88
97
  )}
89
98
  {!unstable && c.wasted > 0 && (
90
99
  <p className="pin-fix">
91
- Fix: {fmtNumber(c.wasted)} renders changed nothing — wrap in{' '}
92
- <code>React.memo</code> or stop the parent re-rendering.
100
+ Fix: {fmtNumber(c.wasted)} renders changed nothing — {wastedFix(pin.fw)}
93
101
  </p>
94
102
  )}
95
103
  {pin.count > 1 && (
@@ -237,8 +245,9 @@ function Callouts({ pins, pageView, active, onActivate, onOpenFile, maxSelf }) {
237
245
 
238
246
  /**
239
247
  * Screenshot of the page at the moment recording stopped, with the slowest
240
- * React components outlined where they sit on screen. Hover or focus a pin for
241
- * the file, render counts and a fix; click it to open the source.
248
+ * components (React, Vue or Angular) outlined where they sit on screen. Hover
249
+ * or focus a pin for the file, render counts and a fix; click it to open the
250
+ * source. Pages without component data still show the screenshot.
242
251
  */
243
252
  export default function PageMap({ report, hideVendor, onOpenFile }) {
244
253
  const { pageView } = report;
@@ -265,30 +274,23 @@ export default function PageMap({ report, hideVendor, onOpenFile }) {
265
274
  <div className="empty">
266
275
  <b>No page map for this report</b>
267
276
  <span>
268
- It needs a development build of a React app and a recording made with
269
- this version of the profiler. Older reports and production builds have
270
- none.
271
- </span>
272
- </div>
273
- );
274
- if (!pins.length)
275
- return (
276
- <div className="empty">
277
- <b>No slow components are visible on screen</b>
278
- <span>
279
- Nothing that rendered during the recording is currently on screen (or
280
- everything is hidden by “Hide node_modules”).
277
+ Recordings from Spectra 0.5 on include a screenshot of every page.
278
+ This one was made with an earlier version, or the screenshot failed.
281
279
  </span>
282
280
  </div>
283
281
  );
284
282
 
283
+ const fw = reportFramework(report);
285
284
  return (
286
- <div className="page-map">
285
+ <div className={`page-map${pins.length ? '' : ' no-pins'}`}>
287
286
  <div className="page-stage">
288
287
  <p className="muted page-caption">
289
- Screenshot taken when the recording stopped. Numbers in the margins
290
- point to the components that spent the most time rendering and are on
291
- screen now; hover a number for details.
288
+ Screenshot taken when the recording stopped.{' '}
289
+ {pins.length
290
+ ? 'Numbers in the margins point to the components that spent the most time rendering and are on screen now; hover a number for details.'
291
+ : fw
292
+ ? `None of the ${fw} components that did measurable work are on screen now${hideVendor ? ', or they are hidden by “Hide node_modules”' : ''}.`
293
+ : 'No React, Vue or Angular components were seen, so there is nothing to pin; the Culprits and File tree tabs show where the time went.'}
292
294
  </p>
293
295
  <Callouts
294
296
  pins={pins}
@@ -299,6 +301,7 @@ export default function PageMap({ report, hideVendor, onOpenFile }) {
299
301
  onOpenFile={onOpenFile}
300
302
  />
301
303
  </div>
304
+ {pins.length > 0 && (
302
305
  <aside className="page-list" aria-label="Components on this page">
303
306
  <h2>Hot spots on this page</h2>
304
307
  <ol>
@@ -324,6 +327,7 @@ export default function PageMap({ report, hideVendor, onOpenFile }) {
324
327
  ))}
325
328
  </ol>
326
329
  </aside>
330
+ )}
327
331
  </div>
328
332
  );
329
333
  }
@@ -1,3 +1,4 @@
1
+ import { frameworkName } from '../framework.js';
1
2
  import { useMemo, useState } from 'react';
2
3
  import { fmtMs, fmtNumber, splitPath } from '../format.js';
3
4
 
@@ -101,10 +102,7 @@ export default function ReactPanel({ report, hideVendor, onOpenFile }) {
101
102
  );
102
103
  }, [react.components, sort, hideVendor]);
103
104
 
104
- const fwLabel =
105
- { vue: 'Vue', svelte: 'Svelte', angular: 'Angular', next: 'React' }[
106
- react.framework
107
- ] || 'React';
105
+ const fwLabel = frameworkName(react.framework) || 'React';
108
106
  if (!react.detected) {
109
107
  return (
110
108
  <div className="empty">
@@ -0,0 +1,291 @@
1
+ // "My app needs a sign-in": how Spectra's Chrome signs in to the app before
2
+ // profiling it. Every kind of web login has a method (server/signin.js does
3
+ // the work). The page URL, the method, the login page, the username and the
4
+ // selectors are remembered in this browser; passwords, codes, tokens and
5
+ // cookies never are: they live in this tab's memory only, until it reloads.
6
+ import { useState } from 'react';
7
+ import { ChevronIcon } from './Icons.jsx';
8
+
9
+ const KEY = 'perf-profiler:sign-in-v2';
10
+ const OLD_KEY = 'perf-profiler:sign-in'; // a sign-in URL template, before 0.5
11
+
12
+ export const SIGN_IN_METHODS = [
13
+ {
14
+ id: 'form',
15
+ title: 'Login form',
16
+ hint: 'A username and password typed into your app’s login page. One step or two, any identity provider, optional authenticator codes.',
17
+ },
18
+ {
19
+ id: 'manual',
20
+ title: 'I’ll sign in myself',
21
+ hint: 'Chrome opens on the login page and waits for you. For SMS or email codes, passkeys, push approvals or CAPTCHAs.',
22
+ },
23
+ {
24
+ id: 'http',
25
+ title: 'Browser prompt',
26
+ hint: 'The browser’s own username and password box (HTTP Basic or Digest), common on staging servers.',
27
+ },
28
+ {
29
+ id: 'token',
30
+ title: 'Token or cookie',
31
+ hint: 'A header, cookies or localStorage entries your app already accepts, set before it loads.',
32
+ },
33
+ {
34
+ id: 'link',
35
+ title: 'Sign-in link',
36
+ hint: 'A URL that signs in by itself, such as a dev-only auto-login.',
37
+ },
38
+ ];
39
+ export const methodTitle = id => SIGN_IN_METHODS.find(m => m.id === id)?.title || 'Sign-in';
40
+
41
+ const PLAIN = ['enabled', 'method', 'url', 'username', 'waitMinutes', 'headerName', 'startSelector', 'usernameSelector', 'passwordSelector', 'submitSelector', 'codeSelector'];
42
+ const SECRET = ['password', 'totpSecret', 'headerValue', 'cookies', 'storage'];
43
+ const EMPTY = { enabled: false, method: 'form', url: '', username: '', waitMinutes: 5, headerName: '', startSelector: '', usernameSelector: '', passwordSelector: '', submitSelector: '', codeSelector: '' };
44
+
45
+ // secrets for this tab only: they survive closing and relaunching Chrome, not a reload
46
+ let secrets = { password: '', totpSecret: '', headerValue: '', cookies: '', storage: '' };
47
+
48
+ function load() {
49
+ try {
50
+ const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
51
+ if (saved) return { ...EMPTY, ...saved };
52
+ const old = localStorage.getItem(OLD_KEY);
53
+ if (old) return { ...EMPTY, enabled: true, method: 'link', url: old };
54
+ } catch {
55
+ // storage blocked: start empty
56
+ }
57
+ return { ...EMPTY };
58
+ }
59
+ function save(value) {
60
+ try {
61
+ localStorage.setItem(KEY, JSON.stringify(Object.fromEntries(PLAIN.map(k => [k, value[k]]))));
62
+ localStorage.removeItem(OLD_KEY);
63
+ } catch {
64
+ // storage blocked: nothing is remembered
65
+ }
66
+ }
67
+
68
+ /** What is missing before this sign-in can work, or '' when it is ready. */
69
+ export function missing(v) {
70
+ if (!v.enabled) return '';
71
+ if (v.method === 'form' && !v.username && !v.password) return 'Add the username and password.';
72
+ if (v.method === 'http' && !v.username) return 'Add the username and password.';
73
+ if (v.method === 'link' && !v.url) return 'Add the sign-in link.';
74
+ if (v.method === 'token' && !(v.headerValue || v.cookies || v.storage)) return 'Add a header, a cookie or a storage entry.';
75
+ return '';
76
+ }
77
+
78
+ /** The sign-in to send to the server: null when it is off. */
79
+ export function toSignIn(v) {
80
+ if (!v.enabled) return null;
81
+ return {
82
+ method: v.method,
83
+ url: v.url,
84
+ username: v.username,
85
+ password: v.password,
86
+ totpSecret: v.totpSecret,
87
+ startSelector: v.startSelector,
88
+ usernameSelector: v.usernameSelector,
89
+ passwordSelector: v.passwordSelector,
90
+ submitSelector: v.submitSelector,
91
+ codeSelector: v.codeSelector,
92
+ headerName: v.headerName,
93
+ headerValue: v.headerValue,
94
+ cookies: v.cookies,
95
+ storage: v.storage,
96
+ waitMs: Math.max(1, Number(v.waitMinutes) || 5) * 60_000,
97
+ };
98
+ }
99
+
100
+ /** The sign-in form's state: [value, update(patch)]. */
101
+ export function useSignIn() {
102
+ const [value, setValue] = useState(() => ({ ...load(), ...secrets }));
103
+ const update = patch =>
104
+ setValue(v => {
105
+ const next = { ...v, ...patch };
106
+ secrets = Object.fromEntries(SECRET.map(k => [k, next[k]]));
107
+ save(next);
108
+ return next;
109
+ });
110
+ return [value, update];
111
+ }
112
+
113
+ function Text({ label, hint, value, onChange, type = 'text', placeholder, secret, wide }) {
114
+ return (
115
+ <label className={`field${wide ? ' wide' : ''}`} title={hint}>
116
+ <span>
117
+ {label}
118
+ {secret && (
119
+ <span className="secret-tag" title="Kept in this tab’s memory only: never saved, logged or put in a report.">
120
+ not saved
121
+ </span>
122
+ )}
123
+ </span>
124
+ <input
125
+ type={type}
126
+ value={value}
127
+ placeholder={placeholder}
128
+ onChange={e => onChange(e.target.value)}
129
+ autoComplete={secret ? 'off' : undefined}
130
+ spellCheck={false}
131
+ />
132
+ </label>
133
+ );
134
+ }
135
+
136
+ function Selectors({ value, update }) {
137
+ const [open, setOpen] = useState(Boolean(value.usernameSelector || value.passwordSelector || value.submitSelector || value.startSelector || value.codeSelector || value.totpSecret));
138
+ const set = k => v => update({ [k]: v });
139
+ return (
140
+ <div className="signin-more">
141
+ <button type="button" className="ghost small" aria-expanded={open} onClick={() => setOpen(o => !o)}>
142
+ One-time codes and fields <ChevronIcon isUp={open} />
143
+ </button>
144
+ {open && (
145
+ <div className="signin-grid">
146
+ <Text
147
+ label="Authenticator secret"
148
+ hint="For apps that ask for a 6-digit code from an authenticator app: the base32 secret behind the QR code (Spectra makes the codes, RFC 6238). Codes sent by SMS or email need “I’ll sign in myself”."
149
+ value={value.totpSecret}
150
+ onChange={set('totpSecret')}
151
+ type="password"
152
+ placeholder="JBSWY3DPEHPK3PXP"
153
+ secret
154
+ wide
155
+ />
156
+ <p className="muted wide">
157
+ Spectra finds the fields by itself, in iframes and web components too. When it can’t, point at them with
158
+ CSS selectors:
159
+ </p>
160
+ <Text label="Button that opens the form" value={value.startSelector} onChange={set('startSelector')} placeholder="#open-login" />
161
+ <Text label="Username field" value={value.usernameSelector} onChange={set('usernameSelector')} placeholder="input[name=email]" />
162
+ <Text label="Password field" value={value.passwordSelector} onChange={set('passwordSelector')} placeholder="input[name=password]" />
163
+ <Text label="Submit button" value={value.submitSelector} onChange={set('submitSelector')} placeholder="button[type=submit]" />
164
+ <Text label="Code field" value={value.codeSelector} onChange={set('codeSelector')} placeholder="input[name=otp]" />
165
+ </div>
166
+ )}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ function Fields({ value, update }) {
172
+ const set = k => v => update({ [k]: v });
173
+ const loginPage = (
174
+ <Text
175
+ label="Login page (optional)"
176
+ hint="Where to sign in. Leave it empty to sign in wherever the app sends a signed-out visitor. {url} in it becomes the page to return to."
177
+ value={value.url}
178
+ onChange={set('url')}
179
+ type="url"
180
+ placeholder="https://app.example.com/login"
181
+ wide
182
+ />
183
+ );
184
+ const user = <Text label="Username or email" value={value.username} onChange={set('username')} placeholder="me@example.com" />;
185
+ const password = <Text label="Password" value={value.password} onChange={set('password')} type="password" secret />;
186
+ switch (value.method) {
187
+ case 'form':
188
+ return (
189
+ <>
190
+ <div className="signin-grid">
191
+ {loginPage}
192
+ {user}
193
+ {password}
194
+ </div>
195
+ <Selectors value={value} update={update} />
196
+ </>
197
+ );
198
+ case 'manual':
199
+ return (
200
+ <div className="signin-grid">
201
+ {loginPage}
202
+ <Text label="Wait up to (minutes)" value={value.waitMinutes} onChange={set('waitMinutes')} type="number" />
203
+ </div>
204
+ );
205
+ case 'http':
206
+ return (
207
+ <div className="signin-grid">
208
+ {user}
209
+ {password}
210
+ </div>
211
+ );
212
+ case 'token':
213
+ return (
214
+ <div className="signin-grid">
215
+ <Text label="Header name" value={value.headerName} onChange={set('headerName')} placeholder="Authorization" />
216
+ <Text
217
+ label="Header value"
218
+ hint="Sent only with requests to the page’s own origin, never to third parties."
219
+ value={value.headerValue}
220
+ onChange={set('headerValue')}
221
+ type="password"
222
+ placeholder="Bearer eyJ…"
223
+ secret
224
+ />
225
+ <Text label="Cookies" value={value.cookies} onChange={set('cookies')} type="password" placeholder="session=abc123; theme=dark" secret />
226
+ <Text
227
+ label="localStorage entries"
228
+ value={value.storage}
229
+ onChange={set('storage')}
230
+ type="password"
231
+ placeholder="auth_token=abc123"
232
+ secret
233
+ />
234
+ </div>
235
+ );
236
+ default:
237
+ return (
238
+ <div className="signin-grid">
239
+ <Text
240
+ label="Sign-in link"
241
+ hint="Opened when the page asks for a login. {url} becomes the page to return to. Do not put tokens in it: it is remembered."
242
+ value={value.url}
243
+ onChange={set('url')}
244
+ placeholder="http://localhost:3000/dev-login?redirect={url}"
245
+ wide
246
+ />
247
+ </div>
248
+ );
249
+ }
250
+ }
251
+
252
+ export default function SignInPanel({ value, update }) {
253
+ const method = SIGN_IN_METHODS.find(m => m.id === value.method) || SIGN_IN_METHODS[0];
254
+ const gap = missing(value);
255
+ return (
256
+ <div className={`signin${value.enabled ? ' on' : ''}`}>
257
+ <label className="switch">
258
+ <input type="checkbox" role="switch" checked={value.enabled} onChange={e => update({ enabled: e.target.checked })} />
259
+ <span>
260
+ <b>My app needs a sign-in</b>
261
+ <span className="muted"> · Chrome starts signed out, so Spectra signs in before it profiles</span>
262
+ </span>
263
+ </label>
264
+ {value.enabled && (
265
+ <div className="signin-body">
266
+ <div className="signin-methods" role="radiogroup" aria-label="How the app signs in">
267
+ {SIGN_IN_METHODS.map(m => (
268
+ <button
269
+ key={m.id}
270
+ type="button"
271
+ role="radio"
272
+ aria-checked={m.id === method.id}
273
+ className={`signin-method${m.id === method.id ? ' selected' : ''}`}
274
+ onClick={() => update({ method: m.id })}
275
+ >
276
+ {m.title}
277
+ </button>
278
+ ))}
279
+ </div>
280
+ <p className="muted signin-hint">{method.hint}</p>
281
+ <Fields value={value} update={update} />
282
+ <p className="signin-note">
283
+ {gap ? <b>{gap} </b> : null}
284
+ Passwords, codes, tokens and cookies stay in this tab’s memory and in Spectra’s: never saved to disk, logged
285
+ or put in a report. Use a test account where you can.
286
+ </p>
287
+ </div>
288
+ )}
289
+ </div>
290
+ );
291
+ }
@@ -28,7 +28,7 @@ const RATINGS = [
28
28
  },
29
29
  {
30
30
  key: 'wasted',
31
- label: 'Wasted React renders',
31
+ label: 'Wasted renders',
32
32
  get: r =>
33
33
  r.react.detected
34
34
  ? (r.react && r.react.components ? r.react.components.reduce((s, c) => s + c.wasted, 0) : 0)
@@ -0,0 +1,12 @@
1
+ // What to call the component framework a page or report uses. Spectra is not
2
+ // tied to React: Vue and Angular get the same component-level data, and any
3
+ // other app still gets files and functions, so labels name the framework that
4
+ // was actually seen, or stay neutral ("components").
5
+ export const FRAMEWORK_NAMES = { react: 'React', next: 'Next.js', vue: 'Vue', angular: 'Angular', svelte: 'Svelte' };
6
+
7
+ /** A framework id ('react', 'vue', …) as a display name; '' when unknown. */
8
+ export const frameworkName = id => FRAMEWORK_NAMES[id] || '';
9
+
10
+ /** The framework a report's component data came from, as a display name ('' when none). */
11
+ export const reportFramework = report =>
12
+ report?.react?.detected ? frameworkName(report.react.framework || 'react') || 'React' : '';
package/src/heat.js CHANGED
@@ -15,9 +15,9 @@ export const FILE_METRICS = [
15
15
  },
16
16
  {
17
17
  id: 'reactMs',
18
- label: 'React render time',
18
+ label: 'Component render time',
19
19
  unit: 'ms',
20
- hint: 'Time React spent rendering components defined in this file (self time).',
20
+ hint: 'Time spent rendering components defined in this file (self time), in React, Vue or Angular.',
21
21
  },
22
22
  {
23
23
  id: 'wasted',
@@ -27,7 +27,7 @@ export const FILE_METRICS = [
27
27
  },
28
28
  {
29
29
  id: 'renders',
30
- label: 'React renders',
30
+ label: 'Component renders',
31
31
  unit: '',
32
32
  hint: 'How many times components in this file rendered.',
33
33
  },
package/src/styles.css CHANGED
@@ -550,6 +550,93 @@ label.switch {
550
550
  flex: 1;
551
551
  min-width: 16rem;
552
552
  }
553
+
554
+ /* ---- sign-in ---- */
555
+ .signin {
556
+ display: grid;
557
+ gap: 0.6rem;
558
+ padding: 0.6rem 0.7rem;
559
+ border: 1px solid var(--border);
560
+ border-radius: var(--radius);
561
+ background: var(--surface-2);
562
+ }
563
+ .signin.on {
564
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
565
+ }
566
+ .signin > .switch {
567
+ align-items: flex-start;
568
+ }
569
+ .signin > .switch b {
570
+ color: var(--text);
571
+ font-weight: 600;
572
+ }
573
+ .signin-body {
574
+ display: grid;
575
+ gap: 0.55rem;
576
+ }
577
+ .signin-methods {
578
+ display: flex;
579
+ flex-wrap: wrap;
580
+ gap: 0.35rem;
581
+ }
582
+ .signin-method {
583
+ min-height: 1.8rem;
584
+ padding: 0.2rem 0.75rem;
585
+ border-radius: 999px;
586
+ color: var(--text-2);
587
+ }
588
+ .signin-method.selected,
589
+ .signin-method.selected:hover {
590
+ background: var(--accent-soft);
591
+ border-color: var(--accent);
592
+ color: var(--accent);
593
+ font-weight: 600;
594
+ }
595
+ .signin-hint {
596
+ margin: 0;
597
+ }
598
+ .signin-grid {
599
+ display: grid;
600
+ grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
601
+ gap: 0.5rem 0.6rem;
602
+ align-items: end;
603
+ }
604
+ .signin-grid .wide {
605
+ grid-column: 1 / -1;
606
+ }
607
+ .signin-grid p {
608
+ margin: 0.2rem 0 0;
609
+ }
610
+ .signin-more {
611
+ display: grid;
612
+ gap: 0.5rem;
613
+ justify-items: start;
614
+ }
615
+ .signin-more > .signin-grid {
616
+ width: 100%;
617
+ }
618
+ button.small {
619
+ min-height: 1.6rem;
620
+ padding: 0.1rem 0.5rem;
621
+ font-size: 0.75rem;
622
+ }
623
+ .secret-tag {
624
+ margin-left: 0.4rem;
625
+ padding: 0 0.4rem;
626
+ border-radius: 999px;
627
+ border: 1px solid var(--border);
628
+ font-size: 0.65rem;
629
+ color: var(--text-muted);
630
+ vertical-align: 1px;
631
+ }
632
+ .signin-note {
633
+ margin: 0;
634
+ font-size: 0.72rem;
635
+ color: var(--text-muted);
636
+ }
637
+ .signin-note b {
638
+ color: var(--warn, var(--text));
639
+ }
553
640
  .pill {
554
641
  display: inline-flex;
555
642
  align-items: center;
@@ -1699,6 +1786,10 @@ svg.change-bar rect {
1699
1786
  padding: 1rem 1.2rem;
1700
1787
  align-items: start;
1701
1788
  }
1789
+ /* a page without components to pin: the screenshot takes the whole width */
1790
+ .page-map.no-pins {
1791
+ grid-template-columns: minmax(0, 1fr);
1792
+ }
1702
1793
  .page-caption {
1703
1794
  margin: 0 0 0.6rem;
1704
1795
  }