@react-perfscope/ui 0.2.0 → 0.4.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/README.md CHANGED
@@ -27,13 +27,12 @@ const unmount = mount({ recorder })
27
27
 
28
28
  Click any row to expand its detail. Each signal kind shows different info:
29
29
 
30
- - **forced-reflow** — call stack showing where layout was forced
30
+ - **forced-reflow** — call stack showing where layout was forced; reads in one turn are coalesced into a single entry carrying its read count
31
31
  - **layout-shift** — CLS value + each source rect
32
32
  - **long-task** — start/end/duration, plus call stack if captured
33
33
  - **network** — full URL, transfer size, render-blocking flag
34
- - **paint** — paint name (first-paint / first-contentful-paint)
35
34
  - **web-vital** — metric name, value, rating per [Google thresholds](https://web.dev/vitals/)
36
- - **render** — component, reason, duration, timestamp
35
+ - **render** — one entry per commit, showing the cascade root and every component that re-rendered
37
36
 
38
37
  The header has a **Save** button that downloads the full recording as JSON. The `render` tab offers **Group by component** and the `forced-reflow` tab offers **Group by source**.
39
38
 
@@ -79,13 +78,12 @@ const unmount = mount({ recorder })
79
78
 
80
79
  신호 row를 클릭하면 디테일이 펼쳐진다. 종류별로 다른 정보를 보여준다:
81
80
 
82
- - **forced-reflow** — 레이아웃을 강제한 호출 스택
81
+ - **forced-reflow** — 레이아웃을 강제한 호출 스택. 한 턴에 일어난 읽기는 읽기 횟수를 담은 한 항목으로 합쳐진다
83
82
  - **layout-shift** — CLS 값 + 각 source rect
84
83
  - **long-task** — 시작/끝/duration, 캡처된 스택이 있으면 같이 표시
85
84
  - **network** — 전체 URL, transfer size, render-blocking 여부
86
- - **paint** — paint 이름 (first-paint / first-contentful-paint)
87
85
  - **web-vital** — 메트릭 이름, 값, [Google 기준](https://web.dev/vitals/)에 따른 등급
88
- - **render** — 컴포넌트, reason, duration, 타임스탬프
86
+ - **render** — 커밋당 항목. 캐스케이드 루트와 그 커밋에서 리렌더된 모든 컴포넌트를 보여준다
89
87
 
90
88
  헤더의 **Save** 버튼으로 전체 recording을 JSON으로 받을 수 있다. `render` 탭에는 **Group by component**, `forced-reflow` 탭에는 **Group by source** 옵션이 있다.
91
89
 
package/dist/index.cjs CHANGED
@@ -105,6 +105,9 @@ var en = {
105
105
  sort: "Sort",
106
106
  sortChronological: "chronological",
107
107
  sortSeverity: "severity (worst first)",
108
+ filterPlaceholder: "Filter\u2026",
109
+ filterAria: "Filter signals",
110
+ filterNoMatches: "No signals match the filter.",
108
111
  groupBy: "Group by",
109
112
  groupChronological: "chronological",
110
113
  groupComponent: "component",
@@ -132,6 +135,8 @@ var en = {
132
135
  blocking: "blocking",
133
136
  sourceCount: (n) => `${n} source(s)`,
134
137
  moreItems: (n) => `+ ${n} more`,
138
+ coalescedReads: (n) => `${n} reads`,
139
+ cascadeComponents: (n) => `${n} components`,
135
140
  startRecording: "Start recording",
136
141
  stopRecording: "Stop recording",
137
142
  rec: "rec",
@@ -193,6 +198,9 @@ var ko = {
193
198
  sort: "\uC815\uB82C",
194
199
  sortChronological: "\uC2DC\uAC04\uC21C",
195
200
  sortSeverity: "\uC2EC\uAC01\uB3C4\uC21C (\uB192\uC740 \uAC83\uBD80\uD130)",
201
+ filterPlaceholder: "\uD544\uD130\u2026",
202
+ filterAria: "\uC2DC\uADF8\uB110 \uD544\uD130",
203
+ filterNoMatches: "\uD544\uD130\uC640 \uC77C\uCE58\uD558\uB294 \uC2DC\uADF8\uB110\uC774 \uC5C6\uC5B4\uC694.",
196
204
  groupBy: "\uADF8\uB8F9\uD654",
197
205
  groupChronological: "\uC2DC\uAC04\uC21C",
198
206
  groupComponent: "\uCEF4\uD3EC\uB10C\uD2B8",
@@ -220,6 +228,8 @@ var ko = {
220
228
  blocking: "\uCC28\uB2E8",
221
229
  sourceCount: (n) => `\uC18C\uC2A4 ${n}\uAC1C`,
222
230
  moreItems: (n) => `\uC678 ${n}\uAC1C \uB354`,
231
+ coalescedReads: (n) => `\uC77D\uAE30 ${n}\uD68C`,
232
+ cascadeComponents: (n) => `\uCEF4\uD3EC\uB10C\uD2B8 ${n}\uAC1C`,
223
233
  startRecording: "\uB179\uD654 \uC2DC\uC791",
224
234
  stopRecording: "\uB179\uD654 \uC885\uB8CC",
225
235
  rec: "\uB179\uD654",
@@ -828,8 +838,10 @@ function renderBreakdown(members) {
828
838
  const counts = /* @__PURE__ */ new Map();
829
839
  for (const s of members) {
830
840
  if (s.kind !== "render") continue;
831
- if (!counts.has(s.component)) order.push(s.component);
832
- counts.set(s.component, (counts.get(s.component) ?? 0) + 1);
841
+ for (const m of s.members ?? [s]) {
842
+ if (!counts.has(m.component)) order.push(m.component);
843
+ counts.set(m.component, (counts.get(m.component) ?? 0) + 1);
844
+ }
833
845
  }
834
846
  return order.map((c) => ({ component: c, count: counts.get(c) }));
835
847
  }
@@ -1731,6 +1743,7 @@ function TooltipContent({ s, startedAt }) {
1731
1743
  s.duration.toFixed(2),
1732
1744
  "ms"
1733
1745
  ] }),
1746
+ (s.count ?? 1) > 1 ? ` \xD7${s.count}` : "",
1734
1747
  " ",
1735
1748
  at
1736
1749
  ] });
@@ -1880,6 +1893,45 @@ function RenderInsights({ signals, onSelect }) {
1880
1893
  );
1881
1894
  }
1882
1895
 
1896
+ // src/filter.ts
1897
+ function signalSearchText(signal) {
1898
+ switch (signal.kind) {
1899
+ case "render": {
1900
+ const parts = [signal.component];
1901
+ if (signal.changedProps) parts.push(...signal.changedProps);
1902
+ if (signal.members) for (const m of signal.members) parts.push(m.component);
1903
+ return parts.join(" ").toLowerCase();
1904
+ }
1905
+ case "network":
1906
+ return signal.url.toLowerCase();
1907
+ case "web-vital":
1908
+ return signal.name.toLowerCase();
1909
+ case "interaction":
1910
+ return [signal.eventType, signal.target ?? ""].join(" ").toLowerCase();
1911
+ case "forced-reflow": {
1912
+ const f = signal.stack[0];
1913
+ return f ? [f.fnName ?? "", f.file].join(" ").toLowerCase() : "";
1914
+ }
1915
+ case "long-task": {
1916
+ const parts = [];
1917
+ if (signal.scripts)
1918
+ for (const s of signal.scripts) parts.push(s.sourceFunctionName, s.invoker, s.sourceURL);
1919
+ if (signal.attribution)
1920
+ for (const a of signal.attribution) parts.push(a.frame.fnName ?? "", a.frame.file);
1921
+ return parts.join(" ").toLowerCase();
1922
+ }
1923
+ case "layout-shift":
1924
+ return "layout-shift";
1925
+ default:
1926
+ return "";
1927
+ }
1928
+ }
1929
+ function signalMatchesFilter(signal, query) {
1930
+ const q = query.trim().toLowerCase();
1931
+ if (!q) return true;
1932
+ return signalSearchText(signal).includes(q);
1933
+ }
1934
+
1883
1935
  // src/panel.tsx
1884
1936
  var import_jsx_runtime5 = require("preact/jsx-runtime");
1885
1937
  var RENDER_REASON_COLOR = {
@@ -2008,7 +2060,7 @@ function summary(s) {
2008
2060
  case "web-vital":
2009
2061
  return `${s.name}: ${s.value.toFixed(2)}`;
2010
2062
  case "render":
2011
- return `${s.component} \u2022 ${s.reason} \u2022 ${s.duration.toFixed(2)}ms`;
2063
+ return `${s.component} \u2022 ${s.reason} \u2022 ${s.duration.toFixed(2)}ms${s.count && s.count > 1 ? ` \u2022 ${s.count} components` : ""}`;
2012
2064
  }
2013
2065
  }
2014
2066
  var detailLabelStyle = { color: "#888", marginRight: "6px" };
@@ -2093,6 +2145,25 @@ function WebVitalDetail({ s }) {
2093
2145
  }
2094
2146
  function RenderDetail({ s }) {
2095
2147
  const { t } = useI18n();
2148
+ if (s.members && s.members.length > 0) {
2149
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: { paddingLeft: "12px" }, children: [
2150
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: detailRowStyle, children: [
2151
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { style: detailLabelStyle, children: t.duration }),
2152
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("span", { children: [
2153
+ s.duration.toFixed(3),
2154
+ "ms"
2155
+ ] })
2156
+ ] }),
2157
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: detailRowStyle, children: [
2158
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { style: detailLabelStyle, children: t.at }),
2159
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("span", { children: [
2160
+ s.at.toFixed(2),
2161
+ "ms"
2162
+ ] })
2163
+ ] }),
2164
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { marginTop: "6px" }, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(CascadeMembers, { members: s.members }) })
2165
+ ] });
2166
+ }
2096
2167
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: { paddingLeft: "12px" }, children: [
2097
2168
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: detailRowStyle, children: [
2098
2169
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { style: detailLabelStyle, children: t.component }),
@@ -2383,6 +2454,7 @@ function SummaryLine({ signal }) {
2383
2454
  ] });
2384
2455
  }
2385
2456
  if (signal.kind === "forced-reflow") {
2457
+ const count = signal.count ?? 1;
2386
2458
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("span", { children: [
2387
2459
  "@ ",
2388
2460
  signal.at.toFixed(1),
@@ -2391,6 +2463,10 @@ function SummaryLine({ signal }) {
2391
2463
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("span", { style: { color }, children: [
2392
2464
  signal.duration.toFixed(2),
2393
2465
  "ms"
2466
+ ] }),
2467
+ count > 1 && /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("span", { style: { color: "#888" }, children: [
2468
+ " \u2022 ",
2469
+ t.coalescedReads(count)
2394
2470
  ] })
2395
2471
  ] });
2396
2472
  }
@@ -2412,7 +2488,8 @@ function SummaryLine({ signal }) {
2412
2488
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("span", { style: { color }, children: [
2413
2489
  signal.duration.toFixed(2),
2414
2490
  "ms"
2415
- ] })
2491
+ ] }),
2492
+ signal.count && signal.count > 1 && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { style: { color: "#888" }, children: t.cascadeComponents(signal.count) })
2416
2493
  ] });
2417
2494
  }
2418
2495
  if (signal.kind === "network") {
@@ -2432,9 +2509,6 @@ function SummaryLine({ signal }) {
2432
2509
  }
2433
2510
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { children: summary(signal) });
2434
2511
  }
2435
- function cascadeRootOf(list) {
2436
- return list.find((s) => s.reason === "state") ?? list.find((s) => s.reason === "mount") ?? [...list].sort((a, b) => a.depth - b.depth)[0];
2437
- }
2438
2512
  function tabSupportsGrouping(kind) {
2439
2513
  return kind === "render" || kind === "forced-reflow";
2440
2514
  }
@@ -2478,22 +2552,17 @@ function groupSignals(signals, mode, kind) {
2478
2552
  const byName = /* @__PURE__ */ new Map();
2479
2553
  for (const s of signals) {
2480
2554
  if (s.kind !== "render") continue;
2481
- const k = s.component;
2482
- if (!byName.has(k)) byName.set(k, []);
2483
- byName.get(k).push(s);
2555
+ for (const m of s.members ?? [s]) {
2556
+ if (!byName.has(m.component)) byName.set(m.component, []);
2557
+ byName.get(m.component).push(m);
2558
+ }
2484
2559
  }
2485
2560
  return Array.from(byName.entries()).sort((a, b) => b[1].length - a[1].length).map(([label, list]) => ({ label, count: list.length, signals: list }));
2486
2561
  }
2487
2562
  if (mode === "commit" && kind === "render") {
2488
- const byCommit = /* @__PURE__ */ new Map();
2489
- for (const s of signals) {
2490
- if (s.kind !== "render") continue;
2491
- if (!byCommit.has(s.commitId)) byCommit.set(s.commitId, []);
2492
- byCommit.get(s.commitId).push(s);
2493
- }
2494
- return Array.from(byCommit.entries()).sort((a, b) => a[0] - b[0]).map(([id, list]) => {
2495
- const root = cascadeRootOf(list);
2496
- return { label: root?.component ?? `commit ${id}`, count: list.length, signals: list };
2563
+ return signals.filter((s) => s.kind === "render").sort((a, b) => a.commitId - b.commitId).map((s) => {
2564
+ const list = s.members ?? [s];
2565
+ return { label: s.component, count: list.length, signals: list };
2497
2566
  });
2498
2567
  }
2499
2568
  if (mode === "source" && kind === "forced-reflow") {
@@ -2628,6 +2697,7 @@ function Panel(props) {
2628
2697
  const [expandedKey, setExpandedKey] = (0, import_hooks3.useState)(null);
2629
2698
  const [groupMode, setGroupMode] = (0, import_hooks3.useState)({});
2630
2699
  const [sortMode, setSortMode] = (0, import_hooks3.useState)({});
2700
+ const [filterText, setFilterText] = (0, import_hooks3.useState)({});
2631
2701
  const activeOverlayCount = (0, import_hooks3.useRef)(0);
2632
2702
  (0, import_hooks3.useEffect)(() => () => hideAllOverlays(), []);
2633
2703
  function handleHover(signal) {
@@ -2834,7 +2904,21 @@ function Panel(props) {
2834
2904
  ]
2835
2905
  }
2836
2906
  )
2837
- ] })
2907
+ ] }),
2908
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
2909
+ "input",
2910
+ {
2911
+ type: "text",
2912
+ "aria-label": t.filterAria,
2913
+ placeholder: t.filterPlaceholder,
2914
+ value: filterText[activeKind] ?? "",
2915
+ onInput: (e) => {
2916
+ setFilterText({ ...filterText, [activeKind]: e.target.value });
2917
+ setExpandedKey(null);
2918
+ },
2919
+ style: { marginLeft: "auto", background: "#1a1a1a", color: "#e6e6e6", border: "1px solid #2a2a2a", borderRadius: "4px", padding: "2px 6px", fontSize: "11px", width: "120px" }
2920
+ }
2921
+ )
2838
2922
  ] }),
2839
2923
  activeTab === "timeline" && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { flexGrow: 1, overflowY: "auto", paddingTop: "4px", paddingBottom: "24px" }, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
2840
2924
  Timeline,
@@ -2855,7 +2939,7 @@ function Panel(props) {
2855
2939
  activeKind === "render" && activeTab !== "timeline" && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
2856
2940
  RenderInsights,
2857
2941
  {
2858
- signals: grouped.render.filter((s) => s.kind === "render"),
2942
+ signals: grouped.render.flatMap((s) => s.kind === "render" ? s.members ?? [s] : []).filter((s) => signalMatchesFilter(s, filterText.render ?? "")),
2859
2943
  onSelect: () => {
2860
2944
  setGroupMode({ ...groupMode, render: "component" });
2861
2945
  setExpandedKey(null);
@@ -2878,7 +2962,10 @@ function Panel(props) {
2878
2962
  }
2879
2963
  ),
2880
2964
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("ul", { style: { listStyle: "none", margin: 0, padding: 0, overflowY: "auto", flexGrow: 1, display: activeTab === "timeline" ? "none" : void 0 }, children: activeKind && (() => {
2881
- const baseSignals = (sortMode[activeKind] ?? "chronological") === "severity" ? sortSignalsBySeverity(grouped[activeKind]) : grouped[activeKind];
2965
+ const filtered = grouped[activeKind].filter(
2966
+ (s) => signalMatchesFilter(s, filterText[activeKind] ?? "")
2967
+ );
2968
+ const baseSignals = (sortMode[activeKind] ?? "chronological") === "severity" ? sortSignalsBySeverity(filtered) : filtered;
2882
2969
  return groupSignals(baseSignals, groupMode[activeKind] ?? "chronological", activeKind);
2883
2970
  })().map((g, gi) => {
2884
2971
  const currentMode = groupMode[activeKind] ?? "chronological";
@@ -2936,7 +3023,8 @@ function Panel(props) {
2936
3023
  },
2937
3024
  key
2938
3025
  );
2939
- }) })
3026
+ }) }),
3027
+ activeKind && activeTab !== "timeline" && (filterText[activeKind] ?? "").trim() !== "" && grouped[activeKind].length > 0 && grouped[activeKind].every((s) => !signalMatchesFilter(s, filterText[activeKind] ?? "")) && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { padding: "12px 8px", fontSize: "11px", color: "#888" }, children: t.filterNoMatches })
2940
3028
  ] })
2941
3029
  ] });
2942
3030
  }