@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.js CHANGED
@@ -57,6 +57,7 @@ function mountShadow(vnode, opts = {}) {
57
57
  }
58
58
 
59
59
  // src/app.tsx
60
+ import { Component } from "preact";
60
61
  import { useState as useState4, useRef as useRef3, useEffect as useEffect2 } from "preact/hooks";
61
62
 
62
63
  // src/i18n.ts
@@ -255,8 +256,8 @@ function isLang(v) {
255
256
  return v === "en" || v === "ko";
256
257
  }
257
258
  function readStoredLang() {
258
- if (typeof localStorage === "undefined") return "en";
259
259
  try {
260
+ if (typeof localStorage === "undefined") return "en";
260
261
  const v = localStorage.getItem(STORAGE_KEY);
261
262
  return isLang(v) ? v : "en";
262
263
  } catch {
@@ -273,11 +274,11 @@ function I18nProvider({ children }) {
273
274
  const [lang, setLangState] = useState(() => readStoredLang());
274
275
  const setLang = useCallback((l) => {
275
276
  setLangState(l);
276
- if (typeof localStorage !== "undefined") {
277
- try {
277
+ try {
278
+ if (typeof localStorage !== "undefined") {
278
279
  localStorage.setItem(STORAGE_KEY, l);
279
- } catch {
280
280
  }
281
+ } catch {
281
282
  }
282
283
  }, []);
283
284
  const value = useMemo(() => ({ lang, setLang, t: STRINGS[lang] }), [lang, setLang]);
@@ -357,7 +358,7 @@ function Widget(props) {
357
358
  }
358
359
 
359
360
  // src/panel.tsx
360
- import { useState as useState3, useMemo as useMemo3, useEffect, useRef as useRef2 } from "preact/hooks";
361
+ import { useState as useState3, useMemo as useMemo5, useEffect, useRef as useRef2 } from "preact/hooks";
361
362
  import { unsupportedKinds } from "@react-perfscope/core";
362
363
 
363
364
  // src/overlay.ts
@@ -410,12 +411,12 @@ function showArrow(id, from, to, color = DEFAULT_BORDER) {
410
411
  const minX = Math.min(from.x, to.x);
411
412
  const minY = Math.min(from.y, to.y);
412
413
  const w = Math.max(2, Math.abs(to.x - from.x));
413
- const h3 = Math.max(2, Math.abs(to.y - from.y));
414
+ const h4 = Math.max(2, Math.abs(to.y - from.y));
414
415
  svg.style.left = `${minX}px`;
415
416
  svg.style.top = `${minY}px`;
416
417
  svg.setAttribute("width", `${w}`);
417
- svg.setAttribute("height", `${h3}`);
418
- svg.setAttribute("viewBox", `0 0 ${w} ${h3}`);
418
+ svg.setAttribute("height", `${h4}`);
419
+ svg.setAttribute("viewBox", `0 0 ${w} ${h4}`);
419
420
  svg.style.opacity = "1";
420
421
  while (svg.firstChild) svg.removeChild(svg.firstChild);
421
422
  const fx = from.x - minX;
@@ -497,7 +498,7 @@ var WEB_VITAL_THRESHOLDS = {
497
498
  TTFB: [800, 1800]
498
499
  };
499
500
  function webVitalRating(name, value) {
500
- const [good, needs] = WEB_VITAL_THRESHOLDS[name];
501
+ const [good, needs] = WEB_VITAL_THRESHOLDS[name] ?? [Infinity, Infinity];
501
502
  if (value <= good) return "good";
502
503
  if (value <= needs) return "needs";
503
504
  return "poor";
@@ -558,15 +559,15 @@ var WEB_VITAL_UNIT = {
558
559
  TTFB: "ms"
559
560
  };
560
561
  function formatVitalValue(name, value) {
561
- if (name === "CLS") return value.toFixed(3);
562
+ const unit = WEB_VITAL_UNIT[name] ?? "ms";
563
+ if (unit !== "ms") return value.toFixed(3);
562
564
  if (value >= 1e3) return (value / 1e3).toFixed(2) + "s";
563
- return value.toFixed(0);
565
+ return value.toFixed(0) + "ms";
564
566
  }
565
567
  function VitalChip({ s }) {
566
568
  const { t } = useI18n();
567
569
  const rating = webVitalRating(s.name, s.value);
568
570
  const color = RATING_COLOR[rating];
569
- const unit = WEB_VITAL_UNIT[s.name];
570
571
  return /* @__PURE__ */ jsxs2(
571
572
  "span",
572
573
  {
@@ -598,10 +599,7 @@ function VitalChip({ s }) {
598
599
  }
599
600
  ),
600
601
  /* @__PURE__ */ jsx2("strong", { style: { color: "#e6e6e6" }, children: s.name }),
601
- /* @__PURE__ */ jsxs2("span", { style: { color }, children: [
602
- formatVitalValue(s.name, s.value),
603
- unit !== "s" ? unit : ""
604
- ] })
602
+ /* @__PURE__ */ jsx2("span", { style: { color }, children: formatVitalValue(s.name, s.value) })
605
603
  ]
606
604
  }
607
605
  );
@@ -1903,6 +1901,7 @@ function RenderInsights({ signals, onSelect }) {
1903
1901
  }
1904
1902
 
1905
1903
  // src/inp-episode.tsx
1904
+ import { useMemo as useMemo3 } from "preact/hooks";
1906
1905
  import { correlate } from "@react-perfscope/core";
1907
1906
 
1908
1907
  // src/episode-shared.tsx
@@ -1973,7 +1972,7 @@ function phaseMs(episode, phase) {
1973
1972
  }
1974
1973
  function InpEpisode({ signals }) {
1975
1974
  const { t } = useI18n();
1976
- const episode = worstInteractionEpisode(signals);
1975
+ const episode = useMemo3(() => worstInteractionEpisode(signals), [signals]);
1977
1976
  if (!episode || episode.anchor.kind !== "interaction") return null;
1978
1977
  const a = episode.anchor;
1979
1978
  const target = a.target ? ` on ${a.target}` : "";
@@ -2029,6 +2028,7 @@ function InpEpisode({ signals }) {
2029
2028
  }
2030
2029
 
2031
2030
  // src/long-task-episode.tsx
2031
+ import { useMemo as useMemo4 } from "preact/hooks";
2032
2032
  import { correlate as correlate2 } from "@react-perfscope/core";
2033
2033
  import { jsx as jsx8, jsxs as jsxs8 } from "preact/jsx-runtime";
2034
2034
  function worstLongTaskEpisode(signals) {
@@ -2041,7 +2041,7 @@ function worstLongTaskEpisode(signals) {
2041
2041
  }
2042
2042
  function LongTaskEpisode({ signals }) {
2043
2043
  const { t } = useI18n();
2044
- const episode = worstLongTaskEpisode(signals);
2044
+ const episode = useMemo4(() => worstLongTaskEpisode(signals), [signals]);
2045
2045
  if (!episode || episode.anchor.kind !== "long-task") return null;
2046
2046
  if (episode.members.length === 0) return null;
2047
2047
  const a = episode.anchor;
@@ -2866,9 +2866,9 @@ function LanguageToggle() {
2866
2866
  function Panel(props) {
2867
2867
  const { result, onClose, position = "bottom-right", resolveFrame } = props;
2868
2868
  const { t } = useI18n();
2869
- const grouped = useMemo3(() => groupByKind(result.signals), [result.signals]);
2869
+ const grouped = useMemo5(() => groupByKind(result.signals), [result.signals]);
2870
2870
  const kindsPresent = KIND_ORDER.filter((k) => grouped[k].length > 0);
2871
- const unsupported = useMemo3(() => unsupportedKinds(), []);
2871
+ const unsupported = useMemo5(() => unsupportedKinds(), []);
2872
2872
  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;
2873
2873
  const [activeTab, setActiveTab] = useState3(
2874
2874
  kindsPresent[0] ?? "forced-reflow"
@@ -3249,6 +3249,65 @@ function Panel(props) {
3249
3249
 
3250
3250
  // src/app.tsx
3251
3251
  import { jsx as jsx10, jsxs as jsxs10 } from "preact/jsx-runtime";
3252
+ var PanelErrorBoundary = class extends Component {
3253
+ constructor() {
3254
+ super(...arguments);
3255
+ this.state = { error: null };
3256
+ }
3257
+ static getDerivedStateFromError(error) {
3258
+ return { error };
3259
+ }
3260
+ componentDidCatch(error) {
3261
+ console.warn("[react-perfscope] panel crashed:", error);
3262
+ }
3263
+ render() {
3264
+ if (this.state.error === null) return this.props.children;
3265
+ return /* @__PURE__ */ jsxs10(
3266
+ "div",
3267
+ {
3268
+ role: "alert",
3269
+ style: {
3270
+ position: "fixed",
3271
+ bottom: "16px",
3272
+ right: "16px",
3273
+ zIndex: 2147483647,
3274
+ display: "flex",
3275
+ alignItems: "center",
3276
+ gap: "8px",
3277
+ padding: "8px 12px",
3278
+ background: "#141414",
3279
+ border: "1px solid #ff3b30",
3280
+ borderRadius: "8px",
3281
+ color: "#e6e6e6",
3282
+ fontSize: "12px",
3283
+ fontFamily: "SF Mono, Menlo, Consolas, monospace"
3284
+ },
3285
+ children: [
3286
+ /* @__PURE__ */ jsx10("span", { children: "react-perfscope panel crashed \u2014 see console" }),
3287
+ /* @__PURE__ */ jsx10(
3288
+ "button",
3289
+ {
3290
+ "aria-label": "Close panel",
3291
+ onClick: () => {
3292
+ this.setState({ error: null });
3293
+ this.props.onClose();
3294
+ },
3295
+ style: {
3296
+ background: "none",
3297
+ border: "1px solid #444",
3298
+ borderRadius: "4px",
3299
+ color: "#e6e6e6",
3300
+ cursor: "pointer",
3301
+ padding: "2px 8px"
3302
+ },
3303
+ children: "\xD7"
3304
+ }
3305
+ )
3306
+ ]
3307
+ }
3308
+ );
3309
+ }
3310
+ };
3252
3311
  function App(props) {
3253
3312
  const { recorder, position = "bottom-right", resolveFrame, finalize } = props;
3254
3313
  const [recording, setRecording] = useState4(false);
@@ -3263,7 +3322,8 @@ function App(props) {
3263
3322
  return;
3264
3323
  }
3265
3324
  const tick = () => {
3266
- setElapsedMs(performance.now() - startedAtRef.current);
3325
+ const elapsed = performance.now() - startedAtRef.current;
3326
+ setElapsedMs(Math.floor(elapsed / 1e3) * 1e3);
3267
3327
  rafRef.current = requestAnimationFrame(tick);
3268
3328
  };
3269
3329
  rafRef.current = requestAnimationFrame(tick);
@@ -3276,12 +3336,23 @@ function App(props) {
3276
3336
  startedAtRef.current = performance.now();
3277
3337
  setElapsedMs(0);
3278
3338
  setResult(null);
3279
- recorder.start();
3339
+ try {
3340
+ recorder.start();
3341
+ } catch (err) {
3342
+ console.warn("[react-perfscope] failed to start recording:", err);
3343
+ return;
3344
+ }
3280
3345
  setRecording(true);
3281
3346
  } else {
3282
- const r = recorder.stop();
3283
- const token = ++resultTokenRef.current;
3284
3347
  setRecording(false);
3348
+ let r;
3349
+ try {
3350
+ r = recorder.stop();
3351
+ } catch (err) {
3352
+ console.warn("[react-perfscope] failed to stop recording:", err);
3353
+ return;
3354
+ }
3355
+ const token = ++resultTokenRef.current;
3285
3356
  setResult(r);
3286
3357
  if (finalize) {
3287
3358
  finalize(r).then((enriched) => {
@@ -3304,14 +3375,36 @@ function App(props) {
3304
3375
  position
3305
3376
  }
3306
3377
  ),
3307
- result !== null && /* @__PURE__ */ jsx10(Panel, { result, position, onClose, resolveFrame })
3378
+ result !== null && /* @__PURE__ */ jsx10(PanelErrorBoundary, { onClose, children: /* @__PURE__ */ jsx10(Panel, { result, position, onClose, resolveFrame }) })
3308
3379
  ] });
3309
3380
  }
3310
3381
 
3311
3382
  // src/mount.tsx
3312
3383
  import { jsx as jsx11 } from "preact/jsx-runtime";
3313
3384
  function mount(opts) {
3314
- const { recorder, position = "bottom-right", host = document.body, resolveFrame, finalize } = opts;
3385
+ if (!opts.host && !document.body) {
3386
+ let torn = false;
3387
+ let unmount = null;
3388
+ const onReady = () => {
3389
+ if (torn || !document.body) return;
3390
+ unmount = mountNow(opts, document.body);
3391
+ };
3392
+ document.addEventListener("DOMContentLoaded", onReady, { once: true });
3393
+ return () => {
3394
+ torn = true;
3395
+ document.removeEventListener("DOMContentLoaded", onReady);
3396
+ unmount?.();
3397
+ };
3398
+ }
3399
+ return mountNow(opts, opts.host ?? document.body);
3400
+ }
3401
+ function mountNow(opts, host) {
3402
+ const { recorder, position = "bottom-right", resolveFrame, finalize } = opts;
3403
+ if (host.querySelector("[data-perfscope-host]")) {
3404
+ console.warn("[react-perfscope] mount(): a widget is already mounted here \u2014 ignoring this call");
3405
+ return () => {
3406
+ };
3407
+ }
3315
3408
  return mountShadow(
3316
3409
  /* @__PURE__ */ jsx11(App, { recorder, position, resolveFrame, finalize }),
3317
3410
  { parent: host }