@react-perfscope/ui 0.7.0 → 1.0.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.
package/dist/index.cjs CHANGED
@@ -90,7 +90,8 @@ function mountShadow(vnode, opts = {}) {
90
90
  }
91
91
 
92
92
  // src/app.tsx
93
- var import_hooks4 = require("preact/hooks");
93
+ var import_preact4 = require("preact");
94
+ var import_hooks6 = require("preact/hooks");
94
95
 
95
96
  // src/i18n.ts
96
97
  var import_preact2 = require("preact");
@@ -288,8 +289,8 @@ function isLang(v) {
288
289
  return v === "en" || v === "ko";
289
290
  }
290
291
  function readStoredLang() {
291
- if (typeof localStorage === "undefined") return "en";
292
292
  try {
293
+ if (typeof localStorage === "undefined") return "en";
293
294
  const v = localStorage.getItem(STORAGE_KEY);
294
295
  return isLang(v) ? v : "en";
295
296
  } catch {
@@ -306,11 +307,11 @@ function I18nProvider({ children }) {
306
307
  const [lang, setLangState] = (0, import_hooks.useState)(() => readStoredLang());
307
308
  const setLang = (0, import_hooks.useCallback)((l) => {
308
309
  setLangState(l);
309
- if (typeof localStorage !== "undefined") {
310
- try {
310
+ try {
311
+ if (typeof localStorage !== "undefined") {
311
312
  localStorage.setItem(STORAGE_KEY, l);
312
- } catch {
313
313
  }
314
+ } catch {
314
315
  }
315
316
  }, []);
316
317
  const value = (0, import_hooks.useMemo)(() => ({ lang, setLang, t: STRINGS[lang] }), [lang, setLang]);
@@ -390,7 +391,7 @@ function Widget(props) {
390
391
  }
391
392
 
392
393
  // src/panel.tsx
393
- var import_hooks3 = require("preact/hooks");
394
+ var import_hooks5 = require("preact/hooks");
394
395
  var import_core4 = require("@react-perfscope/core");
395
396
 
396
397
  // src/overlay.ts
@@ -443,12 +444,12 @@ function showArrow(id, from, to, color = DEFAULT_BORDER) {
443
444
  const minX = Math.min(from.x, to.x);
444
445
  const minY = Math.min(from.y, to.y);
445
446
  const w = Math.max(2, Math.abs(to.x - from.x));
446
- const h3 = Math.max(2, Math.abs(to.y - from.y));
447
+ const h4 = Math.max(2, Math.abs(to.y - from.y));
447
448
  svg.style.left = `${minX}px`;
448
449
  svg.style.top = `${minY}px`;
449
450
  svg.setAttribute("width", `${w}`);
450
- svg.setAttribute("height", `${h3}`);
451
- svg.setAttribute("viewBox", `0 0 ${w} ${h3}`);
451
+ svg.setAttribute("height", `${h4}`);
452
+ svg.setAttribute("viewBox", `0 0 ${w} ${h4}`);
452
453
  svg.style.opacity = "1";
453
454
  while (svg.firstChild) svg.removeChild(svg.firstChild);
454
455
  const fx = from.x - minX;
@@ -530,7 +531,7 @@ var WEB_VITAL_THRESHOLDS = {
530
531
  TTFB: [800, 1800]
531
532
  };
532
533
  function webVitalRating(name, value) {
533
- const [good, needs] = WEB_VITAL_THRESHOLDS[name];
534
+ const [good, needs] = WEB_VITAL_THRESHOLDS[name] ?? [Infinity, Infinity];
534
535
  if (value <= good) return "good";
535
536
  if (value <= needs) return "needs";
536
537
  return "poor";
@@ -591,15 +592,15 @@ var WEB_VITAL_UNIT = {
591
592
  TTFB: "ms"
592
593
  };
593
594
  function formatVitalValue(name, value) {
594
- if (name === "CLS") return value.toFixed(3);
595
+ const unit = WEB_VITAL_UNIT[name] ?? "ms";
596
+ if (unit !== "ms") return value.toFixed(3);
595
597
  if (value >= 1e3) return (value / 1e3).toFixed(2) + "s";
596
- return value.toFixed(0);
598
+ return value.toFixed(0) + "ms";
597
599
  }
598
600
  function VitalChip({ s }) {
599
601
  const { t } = useI18n();
600
602
  const rating = webVitalRating(s.name, s.value);
601
603
  const color = RATING_COLOR[rating];
602
- const unit = WEB_VITAL_UNIT[s.name];
603
604
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
604
605
  "span",
605
606
  {
@@ -631,10 +632,7 @@ function VitalChip({ s }) {
631
632
  }
632
633
  ),
633
634
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("strong", { style: { color: "#e6e6e6" }, children: s.name }),
634
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: { color }, children: [
635
- formatVitalValue(s.name, s.value),
636
- unit !== "s" ? unit : ""
637
- ] })
635
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color }, children: formatVitalValue(s.name, s.value) })
638
636
  ]
639
637
  }
640
638
  );
@@ -1936,6 +1934,7 @@ function RenderInsights({ signals, onSelect }) {
1936
1934
  }
1937
1935
 
1938
1936
  // src/inp-episode.tsx
1937
+ var import_hooks3 = require("preact/hooks");
1939
1938
  var import_core2 = require("@react-perfscope/core");
1940
1939
 
1941
1940
  // src/episode-shared.tsx
@@ -2006,7 +2005,7 @@ function phaseMs(episode, phase) {
2006
2005
  }
2007
2006
  function InpEpisode({ signals }) {
2008
2007
  const { t } = useI18n();
2009
- const episode = worstInteractionEpisode(signals);
2008
+ const episode = (0, import_hooks3.useMemo)(() => worstInteractionEpisode(signals), [signals]);
2010
2009
  if (!episode || episode.anchor.kind !== "interaction") return null;
2011
2010
  const a = episode.anchor;
2012
2011
  const target = a.target ? ` on ${a.target}` : "";
@@ -2062,6 +2061,7 @@ function InpEpisode({ signals }) {
2062
2061
  }
2063
2062
 
2064
2063
  // src/long-task-episode.tsx
2064
+ var import_hooks4 = require("preact/hooks");
2065
2065
  var import_core3 = require("@react-perfscope/core");
2066
2066
  var import_jsx_runtime8 = require("preact/jsx-runtime");
2067
2067
  function worstLongTaskEpisode(signals) {
@@ -2074,7 +2074,7 @@ function worstLongTaskEpisode(signals) {
2074
2074
  }
2075
2075
  function LongTaskEpisode({ signals }) {
2076
2076
  const { t } = useI18n();
2077
- const episode = worstLongTaskEpisode(signals);
2077
+ const episode = (0, import_hooks4.useMemo)(() => worstLongTaskEpisode(signals), [signals]);
2078
2078
  if (!episode || episode.anchor.kind !== "long-task") return null;
2079
2079
  if (episode.members.length === 0) return null;
2080
2080
  const a = episode.anchor;
@@ -2413,9 +2413,9 @@ function StackFrames({
2413
2413
  }) {
2414
2414
  const { t } = useI18n();
2415
2415
  const original = raw.slice(0, limit);
2416
- const [frames, setFrames] = (0, import_hooks3.useState)(original);
2417
- const [resolving, setResolving] = (0, import_hooks3.useState)(false);
2418
- (0, import_hooks3.useEffect)(() => {
2416
+ const [frames, setFrames] = (0, import_hooks5.useState)(original);
2417
+ const [resolving, setResolving] = (0, import_hooks5.useState)(false);
2418
+ (0, import_hooks5.useEffect)(() => {
2419
2419
  if (!resolveFrame || original.length === 0) return;
2420
2420
  let cancelled = false;
2421
2421
  setResolving(true);
@@ -2454,8 +2454,8 @@ function HotFunctions({
2454
2454
  resolveFrame
2455
2455
  }) {
2456
2456
  const { t } = useI18n();
2457
- const [frames, setFrames] = (0, import_hooks3.useState)(attribution.map((a) => a.frame));
2458
- (0, import_hooks3.useEffect)(() => {
2457
+ const [frames, setFrames] = (0, import_hooks5.useState)(attribution.map((a) => a.frame));
2458
+ (0, import_hooks5.useEffect)(() => {
2459
2459
  if (!resolveFrame) return;
2460
2460
  let cancelled = false;
2461
2461
  Promise.all(attribution.map((a) => resolveFrame(a.frame))).then((resolved) => {
@@ -2899,21 +2899,21 @@ function LanguageToggle() {
2899
2899
  function Panel(props) {
2900
2900
  const { result, onClose, position = "bottom-right", resolveFrame } = props;
2901
2901
  const { t } = useI18n();
2902
- const grouped = (0, import_hooks3.useMemo)(() => groupByKind(result.signals), [result.signals]);
2902
+ const grouped = (0, import_hooks5.useMemo)(() => groupByKind(result.signals), [result.signals]);
2903
2903
  const kindsPresent = KIND_ORDER.filter((k) => grouped[k].length > 0);
2904
- const unsupported = (0, import_hooks3.useMemo)(() => (0, import_core4.unsupportedKinds)(), []);
2904
+ const unsupported = (0, import_hooks5.useMemo)(() => (0, import_core4.unsupportedKinds)(), []);
2905
2905
  const hasTimelineSignals = result.signals.some((s) => s.kind !== "web-vital") || (result.heapSamples?.length ?? 0) > 0 || (result.frames?.length ?? 0) > 0 || (result.leakSuspects?.length ?? 0) > 0;
2906
- const [activeTab, setActiveTab] = (0, import_hooks3.useState)(
2906
+ const [activeTab, setActiveTab] = (0, import_hooks5.useState)(
2907
2907
  kindsPresent[0] ?? "forced-reflow"
2908
2908
  );
2909
2909
  const activeKind = activeTab === "timeline" ? null : activeTab;
2910
2910
  const setActiveKind = (k) => setActiveTab(k);
2911
- const [expandedKey, setExpandedKey] = (0, import_hooks3.useState)(null);
2912
- const [groupMode, setGroupMode] = (0, import_hooks3.useState)({});
2913
- const [sortMode, setSortMode] = (0, import_hooks3.useState)({});
2914
- const [filterText, setFilterText] = (0, import_hooks3.useState)({});
2915
- const activeOverlayCount = (0, import_hooks3.useRef)(0);
2916
- (0, import_hooks3.useEffect)(() => () => hideAllOverlays(), []);
2911
+ const [expandedKey, setExpandedKey] = (0, import_hooks5.useState)(null);
2912
+ const [groupMode, setGroupMode] = (0, import_hooks5.useState)({});
2913
+ const [sortMode, setSortMode] = (0, import_hooks5.useState)({});
2914
+ const [filterText, setFilterText] = (0, import_hooks5.useState)({});
2915
+ const activeOverlayCount = (0, import_hooks5.useRef)(0);
2916
+ (0, import_hooks5.useEffect)(() => () => hideAllOverlays(), []);
2917
2917
  function handleHover(signal) {
2918
2918
  if (!signal) {
2919
2919
  for (let i = 0; i < activeOverlayCount.current; i++) {
@@ -3282,21 +3282,81 @@ function Panel(props) {
3282
3282
 
3283
3283
  // src/app.tsx
3284
3284
  var import_jsx_runtime10 = require("preact/jsx-runtime");
3285
+ var PanelErrorBoundary = class extends import_preact4.Component {
3286
+ constructor() {
3287
+ super(...arguments);
3288
+ this.state = { error: null };
3289
+ }
3290
+ static getDerivedStateFromError(error) {
3291
+ return { error };
3292
+ }
3293
+ componentDidCatch(error) {
3294
+ console.warn("[react-perfscope] panel crashed:", error);
3295
+ }
3296
+ render() {
3297
+ if (this.state.error === null) return this.props.children;
3298
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
3299
+ "div",
3300
+ {
3301
+ role: "alert",
3302
+ style: {
3303
+ position: "fixed",
3304
+ bottom: "16px",
3305
+ right: "16px",
3306
+ zIndex: 2147483647,
3307
+ display: "flex",
3308
+ alignItems: "center",
3309
+ gap: "8px",
3310
+ padding: "8px 12px",
3311
+ background: "#141414",
3312
+ border: "1px solid #ff3b30",
3313
+ borderRadius: "8px",
3314
+ color: "#e6e6e6",
3315
+ fontSize: "12px",
3316
+ fontFamily: "SF Mono, Menlo, Consolas, monospace"
3317
+ },
3318
+ children: [
3319
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { children: "react-perfscope panel crashed \u2014 see console" }),
3320
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
3321
+ "button",
3322
+ {
3323
+ "aria-label": "Close panel",
3324
+ onClick: () => {
3325
+ this.setState({ error: null });
3326
+ this.props.onClose();
3327
+ },
3328
+ style: {
3329
+ background: "none",
3330
+ border: "1px solid #444",
3331
+ borderRadius: "4px",
3332
+ color: "#e6e6e6",
3333
+ cursor: "pointer",
3334
+ padding: "2px 8px"
3335
+ },
3336
+ children: "\xD7"
3337
+ }
3338
+ )
3339
+ ]
3340
+ }
3341
+ );
3342
+ }
3343
+ };
3285
3344
  function App(props) {
3286
3345
  const { recorder, position = "bottom-right", resolveFrame, finalize } = props;
3287
- const [recording, setRecording] = (0, import_hooks4.useState)(false);
3288
- const [result, setResult] = (0, import_hooks4.useState)(null);
3289
- const [elapsedMs, setElapsedMs] = (0, import_hooks4.useState)(0);
3290
- const startedAtRef = (0, import_hooks4.useRef)(0);
3291
- const rafRef = (0, import_hooks4.useRef)(null);
3292
- const resultTokenRef = (0, import_hooks4.useRef)(0);
3293
- (0, import_hooks4.useEffect)(() => {
3346
+ const [recording, setRecording] = (0, import_hooks6.useState)(false);
3347
+ const [result, setResult] = (0, import_hooks6.useState)(null);
3348
+ const [elapsedMs, setElapsedMs] = (0, import_hooks6.useState)(0);
3349
+ const startedAtRef = (0, import_hooks6.useRef)(0);
3350
+ const rafRef = (0, import_hooks6.useRef)(null);
3351
+ const resultTokenRef = (0, import_hooks6.useRef)(0);
3352
+ (0, import_hooks6.useEffect)(() => {
3294
3353
  if (!recording) {
3295
3354
  if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
3296
3355
  return;
3297
3356
  }
3298
3357
  const tick = () => {
3299
- setElapsedMs(performance.now() - startedAtRef.current);
3358
+ const elapsed = performance.now() - startedAtRef.current;
3359
+ setElapsedMs(Math.floor(elapsed / 1e3) * 1e3);
3300
3360
  rafRef.current = requestAnimationFrame(tick);
3301
3361
  };
3302
3362
  rafRef.current = requestAnimationFrame(tick);
@@ -3309,12 +3369,23 @@ function App(props) {
3309
3369
  startedAtRef.current = performance.now();
3310
3370
  setElapsedMs(0);
3311
3371
  setResult(null);
3312
- recorder.start();
3372
+ try {
3373
+ recorder.start();
3374
+ } catch (err) {
3375
+ console.warn("[react-perfscope] failed to start recording:", err);
3376
+ return;
3377
+ }
3313
3378
  setRecording(true);
3314
3379
  } else {
3315
- const r = recorder.stop();
3316
- const token = ++resultTokenRef.current;
3317
3380
  setRecording(false);
3381
+ let r;
3382
+ try {
3383
+ r = recorder.stop();
3384
+ } catch (err) {
3385
+ console.warn("[react-perfscope] failed to stop recording:", err);
3386
+ return;
3387
+ }
3388
+ const token = ++resultTokenRef.current;
3318
3389
  setResult(r);
3319
3390
  if (finalize) {
3320
3391
  finalize(r).then((enriched) => {
@@ -3337,14 +3408,36 @@ function App(props) {
3337
3408
  position
3338
3409
  }
3339
3410
  ),
3340
- result !== null && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Panel, { result, position, onClose, resolveFrame })
3411
+ result !== null && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(PanelErrorBoundary, { onClose, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Panel, { result, position, onClose, resolveFrame }) })
3341
3412
  ] });
3342
3413
  }
3343
3414
 
3344
3415
  // src/mount.tsx
3345
3416
  var import_jsx_runtime11 = require("preact/jsx-runtime");
3346
3417
  function mount(opts) {
3347
- const { recorder, position = "bottom-right", host = document.body, resolveFrame, finalize } = opts;
3418
+ if (!opts.host && !document.body) {
3419
+ let torn = false;
3420
+ let unmount = null;
3421
+ const onReady = () => {
3422
+ if (torn || !document.body) return;
3423
+ unmount = mountNow(opts, document.body);
3424
+ };
3425
+ document.addEventListener("DOMContentLoaded", onReady, { once: true });
3426
+ return () => {
3427
+ torn = true;
3428
+ document.removeEventListener("DOMContentLoaded", onReady);
3429
+ unmount?.();
3430
+ };
3431
+ }
3432
+ return mountNow(opts, opts.host ?? document.body);
3433
+ }
3434
+ function mountNow(opts, host) {
3435
+ const { recorder, position = "bottom-right", resolveFrame, finalize } = opts;
3436
+ if (host.querySelector("[data-perfscope-host]")) {
3437
+ console.warn("[react-perfscope] mount(): a widget is already mounted here \u2014 ignoring this call");
3438
+ return () => {
3439
+ };
3440
+ }
3348
3441
  return mountShadow(
3349
3442
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(App, { recorder, position, resolveFrame, finalize }),
3350
3443
  { parent: host }