rerender-lens 0.5.0 → 0.7.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +23 -4
  3. package/dist/{budget-CRswn-nC.d.cts → budget-CA1MS7lB.d.cts} +1 -1
  4. package/dist/{budget-BnJlYwrV.d.ts → budget-ok1VDJ4t.d.ts} +1 -1
  5. package/dist/cli.cjs +78 -26
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +78 -26
  8. package/dist/cli.js.map +1 -1
  9. package/dist/{devtools-BYWat7nQ.d.ts → devtools-Cl5n_52v.d.ts} +11 -2
  10. package/dist/{devtools-DJi_6AfJ.d.cts → devtools-UwQx2VYS.d.cts} +11 -2
  11. package/dist/index.cjs +99 -9
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +13 -7
  14. package/dist/index.d.ts +13 -7
  15. package/dist/index.js +99 -10
  16. package/dist/index.js.map +1 -1
  17. package/dist/jest-setup.cjs +20 -3
  18. package/dist/jest-setup.cjs.map +1 -1
  19. package/dist/jest-setup.d.cts +4 -4
  20. package/dist/jest-setup.d.ts +4 -4
  21. package/dist/jest-setup.js +20 -3
  22. package/dist/jest-setup.js.map +1 -1
  23. package/dist/jest.cjs +20 -3
  24. package/dist/jest.cjs.map +1 -1
  25. package/dist/jest.d.cts +4 -4
  26. package/dist/jest.d.ts +4 -4
  27. package/dist/jest.js +20 -3
  28. package/dist/jest.js.map +1 -1
  29. package/dist/{notifiers-DwKlvv4H.d.cts → notifiers-B5poAEvE.d.cts} +2 -2
  30. package/dist/{notifiers-FNzKHs0a.d.ts → notifiers-CqQW0Eum.d.ts} +2 -2
  31. package/dist/playwright.d.cts +4 -4
  32. package/dist/playwright.d.ts +4 -4
  33. package/dist/relay.cjs +65 -21
  34. package/dist/relay.cjs.map +1 -1
  35. package/dist/relay.d.cts +25 -3
  36. package/dist/relay.d.ts +25 -3
  37. package/dist/relay.js +65 -21
  38. package/dist/relay.js.map +1 -1
  39. package/dist/rerender-lens.iife.js +105 -12
  40. package/dist/{runner-report-CW8X5RwK.d.ts → runner-report-Bvqoe89M.d.ts} +2 -2
  41. package/dist/{runner-report-B8Sc3FZO.d.cts → runner-report-CvpDg2zt.d.cts} +2 -2
  42. package/dist/setup.cjs +98 -9
  43. package/dist/setup.cjs.map +1 -1
  44. package/dist/setup.js +98 -9
  45. package/dist/setup.js.map +1 -1
  46. package/dist/{types-DW5-N2XH.d.cts → types-DwNN5cWe.d.cts} +28 -1
  47. package/dist/{types-DW5-N2XH.d.ts → types-DwNN5cWe.d.ts} +28 -1
  48. package/dist/vite.d.cts +1 -1
  49. package/dist/vite.d.ts +1 -1
  50. package/dist/vitest-setup.cjs +20 -3
  51. package/dist/vitest-setup.cjs.map +1 -1
  52. package/dist/vitest-setup.d.cts +4 -4
  53. package/dist/vitest-setup.d.ts +4 -4
  54. package/dist/vitest-setup.js +20 -3
  55. package/dist/vitest-setup.js.map +1 -1
  56. package/dist/vitest.cjs +20 -3
  57. package/dist/vitest.cjs.map +1 -1
  58. package/dist/vitest.d.cts +5 -5
  59. package/dist/vitest.d.ts +5 -5
  60. package/dist/vitest.js +20 -3
  61. package/dist/vitest.js.map +1 -1
  62. package/package.json +3 -2
  63. package/panel/panel.css +48 -0
  64. package/panel/panel.js +348 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rerender-lens",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Find avoidable React re-renders and what caused them. Reads the fiber tree like React DevTools, so nothing is patched: works with Fast Refresh, memo, forwardRef and classes. Structured reports, test assertions, and a DevTools bridge.",
5
5
  "keywords": [
6
6
  "react",
@@ -149,7 +149,8 @@
149
149
  "test:jest": "node scripts/jest-check.mjs",
150
150
  "test:react18": "npm install --prefix .react18 --no-save --no-package-lock --no-audit --no-fund react@18 react-dom@18 && vitest run --config test/react-compat.config.ts",
151
151
  "store:media": "node scripts/store-media.mjs",
152
- "test:react17": "npm install --prefix .react17 --no-save --no-package-lock --no-audit --no-fund react@17 react-dom@17 && REACT_COMPAT_DIR=.react17/node_modules vitest run --config test/react-compat.config.ts"
152
+ "test:react17": "npm install --prefix .react17 --no-save --no-package-lock --no-audit --no-fund react@17 react-dom@17 && REACT_COMPAT_DIR=.react17/node_modules vitest run --config test/react-compat.config.ts",
153
+ "e2e:perf": "playwright test -c e2e/perf.config.ts"
153
154
  },
154
155
  "peerDependencies": {
155
156
  "react": ">=16.8.0",
package/panel/panel.css CHANGED
@@ -105,6 +105,11 @@ button.ib.active .glyph { color: var(--accent); }
105
105
  .status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }
106
106
  .status.connected .dot { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent); }
107
107
  .status.partial .dot { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 18%, transparent); }
108
+ .app-picker {
109
+ height: 22px; max-width: 200px; padding: 0 4px; font-size: 11px; color: var(--text-dim);
110
+ background: var(--bg-inset); border: 1px solid var(--border); border-radius: 10px;
111
+ }
112
+ .app-picker:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
108
113
  .tab-chip {
109
114
  max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
110
115
  padding: 2px 8px; border-radius: 10px; background: var(--bg-inset); color: var(--text-dim); font-size: 11px;
@@ -123,6 +128,43 @@ button.ib.active .glyph { color: var(--accent); }
123
128
  .summary button.stat { height: 22px; padding: 0 6px; margin: 0 -6px; }
124
129
  .summary button.stat:hover { background: var(--hover); }
125
130
 
131
+ /* ---------- commit timeline strip ---------- */
132
+ .timeline { flex: none; background: var(--bg-alt); border-bottom: 1px solid var(--border); }
133
+ .timeline-header {
134
+ display: flex; align-items: center; gap: 6px; padding: 1px 12px; height: 17px;
135
+ color: var(--text-dim); cursor: pointer; user-select: none; font-size: 10px;
136
+ }
137
+ .timeline-header:hover { color: var(--text); }
138
+ .timeline-header .chevron { width: 9px; flex: none; }
139
+ .timeline-header .count { margin-left: auto; font-variant-numeric: tabular-nums; }
140
+ .timeline-header .chip {
141
+ height: 15px; padding: 0 7px; font-size: 10px; border-radius: 8px;
142
+ border: 1px solid var(--accent); background: var(--accent-bg); color: var(--accent);
143
+ }
144
+ .timeline-header .chip:hover { background: var(--accent); color: var(--bg); }
145
+ .timeline-body { padding: 0 12px 2px; }
146
+ .timeline.collapsed .timeline-body { display: none; }
147
+ .timeline .bars {
148
+ display: flex; align-items: flex-end; gap: 1px; height: 26px; padding: 0 2px;
149
+ background: var(--bg-inset); border-radius: 3px; overflow: hidden; cursor: crosshair;
150
+ }
151
+ .timeline .bar {
152
+ flex: 1 1 0; min-width: 2px; height: 100%; padding: 0; margin: 0; display: block;
153
+ position: relative; align-self: flex-end; border: 0; border-radius: 1px 1px 0 0;
154
+ background: var(--ok); opacity: 0.65; cursor: pointer;
155
+ }
156
+ .timeline .bar:hover { background: var(--ok); opacity: 1; }
157
+ .timeline .bar .avoid { position: absolute; left: 0; right: 0; bottom: 0; background: var(--danger); }
158
+ .timeline .bar.in-window, .timeline .bar.brushing { opacity: 1; }
159
+ .timeline .bar.selected { opacity: 1; outline: 1px solid var(--accent); outline-offset: -1px; }
160
+ .timeline .bar:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; opacity: 1; }
161
+ .timeline-axis {
162
+ display: flex; justify-content: space-between; height: 10px; line-height: 10px;
163
+ pointer-events: none; font-size: 9px; color: var(--text-faint); font-variant-numeric: tabular-nums;
164
+ }
165
+ .compact .timeline-header, .compact .timeline-body { padding-left: 8px; padding-right: 8px; }
166
+ .compact .timeline .bars { height: 22px; }
167
+
126
168
  /* ---------- banner, toast ---------- */
127
169
  .banner { padding: 6px 12px; background: var(--warn-bg); color: var(--text); border-bottom: 1px solid var(--border); font-size: 11px; flex: none; }
128
170
  .banner div { padding-left: 16px; position: relative; }
@@ -175,6 +217,12 @@ button.ib.active .glyph { color: var(--accent); }
175
217
  @keyframes flash { from { box-shadow: inset 3px 0 0 var(--warn); background: var(--warn-bg); } to { box-shadow: none; background: transparent; } }
176
218
  .empty { padding: 28px 18px; color: var(--text-dim); text-align: center; line-height: 1.6; }
177
219
  .empty code { font-family: var(--mono); font-size: 11px; background: var(--bg-inset); padding: 1px 5px; border-radius: 4px; }
220
+ .empty .empty-head { color: var(--text); font-weight: 600; margin-bottom: 6px; }
221
+ .empty .empty-tracking { color: var(--text); background: var(--bg-inset); border-radius: 6px; padding: 6px 10px; margin: 0 auto 8px; display: inline-block; max-width: 46em; }
222
+ .empty-actions { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; margin-top: 10px; }
223
+ .empty-actions button { border: 1px solid var(--border); }
224
+ .empty-actions button.primary { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
225
+ .empty-actions input.empty-match { border: 1px solid var(--border); border-radius: 4px; padding: 0 6px; height: 24px; background: var(--bg-inset); width: 12em; }
178
226
 
179
227
  /* ---------- details ---------- */
180
228
  .details { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); }
package/panel/panel.js CHANGED
@@ -577,6 +577,8 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
577
577
  var ITEM_H = 20;
578
578
  var OVERSCAN = 8;
579
579
  var FALLBACK_VIEWPORT = 800;
580
+ var MAX_STRIP_BARS = 200;
581
+ var MIN_BAR_PCT = 8;
580
582
  function el(tag, attrs, children) {
581
583
  const node = document.createElement(tag);
582
584
  if (attrs) {
@@ -602,6 +604,8 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
602
604
  }
603
605
  var fmtMs = (n) => typeof n === "number" && Number.isFinite(n) ? `${n.toFixed(1)} ms` : "";
604
606
  var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
607
+ var fmtSpan = (ms) => ms < 1e3 ? `${Math.round(ms)}ms` : ms < 6e4 ? `${(ms / 1e3).toFixed(1)}s` : `${(ms / 6e4).toFixed(1)}m`;
608
+ var spokenAgo = (ms) => ms < 1e3 ? "just now" : ms < 6e4 ? `${Math.round(ms / 1e3)} seconds ago` : `${Math.round(ms / 6e4)} minutes ago`;
605
609
  var componentList = (m) => (m instanceof Map ? [...m] : Object.entries(m)).map(([c, n]) => `<${c}>${n > 1 ? " \xD7" + n : ""}`).join(", ");
606
610
  function changesOf(report) {
607
611
  return [].concat(report.propChanges || [], report.stateChanges || [], report.hookChanges || []);
@@ -999,6 +1003,8 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
999
1003
  reports: [],
1000
1004
  commits: /* @__PURE__ */ new Map(),
1001
1005
  commitOrder: [],
1006
+ apps: [],
1007
+ appId: null,
1002
1008
  selectedKey: null,
1003
1009
  selectedReport: null,
1004
1010
  selectedCommit: null,
@@ -1024,7 +1030,9 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1024
1030
  recording: null,
1025
1031
  selectedSession: null,
1026
1032
  compareWith: null,
1027
- byInstance: false
1033
+ byInstance: false,
1034
+ timeWindow: null,
1035
+ stripCollapsed: false
1028
1036
  };
1029
1037
  let persistTimer = null;
1030
1038
  let queue = [];
@@ -1084,6 +1092,8 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1084
1092
  const settingsBtn = iconButton("\u2699", "Settings", "Settings", () => toggleSettings());
1085
1093
  const status = el("span", { class: "status", title: "" }, [el("span", { class: "dot" }), el("span", { class: "status-text", text: "no page" })]);
1086
1094
  const tabChip = el("span", { class: "tab-chip", hidden: true, title: "The tab this panel follows" });
1095
+ const appPicker = el("select", { class: "app-picker", hidden: true, title: "Which connected app this panel watches", "aria-label": "App" });
1096
+ appPicker.addEventListener("change", () => transport.selectApp?.(appPicker.value));
1087
1097
  const undock = transport.undock ? [
1088
1098
  el("span", { class: "sep" }),
1089
1099
  iconButton("\u2AFF", "Side panel", "Show this panel next to the page (Chrome side panel)", () => void transport.undock("sidepanel").catch((e) => toast(String(e.message || e)))),
@@ -1105,11 +1115,31 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1105
1115
  ...undock,
1106
1116
  el("span", { class: "spacer" }),
1107
1117
  tabChip,
1118
+ appPicker,
1108
1119
  status,
1109
1120
  settingsBtn
1110
1121
  ]);
1111
1122
  const summary = el("div", { class: "summary" });
1112
1123
  const banner = el("div", { class: "banner", hidden: true });
1124
+ const stripChevron = el("span", { class: "chevron", text: "\u25BE" });
1125
+ const stripCount = el("span", { class: "count", text: "0 commits" });
1126
+ const stripChip = el("button", {
1127
+ class: "chip",
1128
+ hidden: true,
1129
+ title: "Clear the time window (Esc)",
1130
+ onclick: (e) => {
1131
+ e.stopPropagation();
1132
+ clearWindow();
1133
+ }
1134
+ });
1135
+ const stripBars = el("div", { class: "bars" });
1136
+ const stripFrom = el("span", { class: "from", text: "" });
1137
+ const stripTo = el("span", { class: "to", text: "now" });
1138
+ const stripHeader = el("div", { class: "timeline-header", onclick: () => toggleStrip() }, [stripChevron, el("span", { class: "title", text: "Timeline" }), stripChip, stripCount]);
1139
+ const timeline = el("div", { class: "timeline", hidden: true, role: "group", "aria-label": "Commit timeline" }, [
1140
+ stripHeader,
1141
+ el("div", { class: "timeline-body" }, [stripBars, el("div", { class: "timeline-axis" }, [stripFrom, stripTo])])
1142
+ ]);
1113
1143
  const viewsBar = el("div", { class: "views" });
1114
1144
  const VIEWS = [
1115
1145
  ["tree", "Tree"],
@@ -1172,7 +1202,9 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1172
1202
  details.setAttribute("aria-label", "Details");
1173
1203
  settings.setAttribute("role", "dialog");
1174
1204
  settings.setAttribute("aria-label", "Settings");
1175
- root.append(toolbar, summary, banner, main, stream, toastEl);
1205
+ stripBars.setAttribute("role", "group");
1206
+ stripBars.setAttribute("aria-label", "Commits over time; arrow keys move between commits, Enter selects, drag to filter every view to a time window");
1207
+ root.append(toolbar, summary, banner, timeline, main, stream, toastEl);
1176
1208
  root.addEventListener("keydown", onGlobalKey);
1177
1209
  function setCompact(on) {
1178
1210
  if (state.compact === on) return;
@@ -1291,7 +1323,8 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1291
1323
  collapsed: [...state.collapsed],
1292
1324
  treeWidth: state.treeWidth,
1293
1325
  flashOn: state.flashOn,
1294
- byInstance: state.byInstance
1326
+ byInstance: state.byInstance,
1327
+ stripCollapsed: state.stripCollapsed
1295
1328
  };
1296
1329
  transport.storage.set("panel", saved);
1297
1330
  }, 150);
@@ -1343,6 +1376,7 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1343
1376
  instancesBtn.classList.toggle("active", state.byInstance);
1344
1377
  rebuildTree();
1345
1378
  }
1379
+ if (typeof saved.stripCollapsed === "boolean" && saved.stripCollapsed !== state.stripCollapsed) toggleStrip(saved.stripCollapsed);
1346
1380
  if (saved.tab === "history" || saved.tab === "fix") state.tab = saved.tab;
1347
1381
  if (saved.view && viewButtons.has(saved.view)) state.view = saved.view;
1348
1382
  for (const n of state.nodesByKey.values()) n.expanded = !state.collapsed.has(n.key);
@@ -1498,6 +1532,7 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1498
1532
  function renderHeavy(details2) {
1499
1533
  heavyAt = Date.now();
1500
1534
  if (state.view !== "tree") renderLeft();
1535
+ renderStrip();
1501
1536
  if (details2) renderDetails();
1502
1537
  }
1503
1538
  function scheduleHeavy(details2) {
@@ -1544,12 +1579,14 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1544
1579
  state.selectedCommit = null;
1545
1580
  state.selectedFix = null;
1546
1581
  state.selectedRoot = null;
1582
+ state.timeWindow = null;
1547
1583
  if (state.tab === "commit" || state.tab === "fixlist" || state.tab === "root") state.tab = "latest";
1548
1584
  queue = [];
1549
1585
  renderLeft();
1550
1586
  renderDetails();
1551
1587
  renderStream();
1552
1588
  renderSummary();
1589
+ renderStrip();
1553
1590
  }
1554
1591
  let filterCache = { filter: "", value: null, regex: null, text: "" };
1555
1592
  function parsedFilter() {
@@ -1590,17 +1627,45 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1590
1627
  }
1591
1628
  return text.includes(q);
1592
1629
  }
1630
+ const inWindow = (r) => {
1631
+ const w = state.timeWindow;
1632
+ return !w || r.receivedAt >= w.from && r.receivedAt <= w.to;
1633
+ };
1634
+ const windowKey = () => {
1635
+ const w = state.timeWindow;
1636
+ return w ? `${w.from}-${w.to}` : "";
1637
+ };
1638
+ const NO_COUNTS = { total: 0, avoidable: 0 };
1639
+ let windowNodes = null;
1640
+ function nodeCounts(node) {
1641
+ if (!state.timeWindow) return node;
1642
+ const key = `${dataGen}|${windowKey()}|${state.byInstance}`;
1643
+ if (!windowNodes || windowNodes.key !== key) {
1644
+ const map = /* @__PURE__ */ new Map();
1645
+ for (const r of state.reports) {
1646
+ if (!inWindow(r)) continue;
1647
+ const k = nodeOfReport(r).key;
1648
+ let c = map.get(k);
1649
+ if (!c) map.set(k, c = { total: 0, avoidable: 0 });
1650
+ c.total++;
1651
+ if (r.avoidable) c.avoidable++;
1652
+ }
1653
+ windowNodes = { key, map };
1654
+ }
1655
+ return windowNodes.map.get(node.key) || NO_COUNTS;
1656
+ }
1593
1657
  function visible(node) {
1594
- const own = (!state.avoidableOnly || node.avoidable > 0) && matchesFilter(node.name) && node.total > 0 && (valueQuery() === null || node.reports.some(matchesValues));
1658
+ const c = nodeCounts(node);
1659
+ const own = (!state.avoidableOnly || c.avoidable > 0) && matchesFilter(node.name) && c.total > 0 && (valueQuery() === null || node.reports.some(matchesValues));
1595
1660
  if (own) return true;
1596
- for (const c of node.children.values()) if (visible(c)) return true;
1661
+ for (const child of node.children.values()) if (visible(child)) return true;
1597
1662
  return false;
1598
1663
  }
1599
- const passes = (r) => (!state.avoidableOnly || r.avoidable) && matchesFilter(r.component) && matchesValues(r);
1664
+ const passes = (r) => inWindow(r) && (!state.avoidableOnly || r.avoidable) && matchesFilter(r.component) && matchesValues(r);
1600
1665
  let dataGen = 0;
1601
1666
  let filtered = { key: "", reports: [], fixes: null };
1602
1667
  function filteredReports() {
1603
- const key = `${dataGen}|${state.avoidableOnly}|${state.filter}`;
1668
+ const key = `${dataGen}|${state.avoidableOnly}|${state.filter}|${windowKey()}`;
1604
1669
  if (filtered.key !== key) filtered = { key, reports: state.reports.filter(passes), fixes: null };
1605
1670
  return filtered.reports;
1606
1671
  }
@@ -1618,6 +1683,154 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1618
1683
  renderDetails();
1619
1684
  persist();
1620
1685
  }
1686
+ let bars = [];
1687
+ const barEls = /* @__PURE__ */ new Map();
1688
+ const barNodes = () => [...stripBars.children];
1689
+ function renderStrip() {
1690
+ const order = state.commitOrder;
1691
+ const next = [];
1692
+ let max = 1;
1693
+ for (let i = Math.max(0, order.length - MAX_STRIP_BARS); i < order.length; i++) {
1694
+ const key = order[i];
1695
+ const reports = state.commits.get(key);
1696
+ if (!reports || !reports.length) continue;
1697
+ let avoidable = 0;
1698
+ let from = Infinity;
1699
+ let to = 0;
1700
+ for (const r of reports) {
1701
+ if (r.avoidable) avoidable++;
1702
+ if (r.receivedAt < from) from = r.receivedAt;
1703
+ if (r.receivedAt > to) to = r.receivedAt;
1704
+ }
1705
+ next.push({ key, total: reports.length, avoidable, from, to });
1706
+ if (reports.length > max) max = reports.length;
1707
+ }
1708
+ bars = next;
1709
+ if (!bars.length) {
1710
+ timeline.hidden = true;
1711
+ stripBars.textContent = "";
1712
+ barEls.clear();
1713
+ return;
1714
+ }
1715
+ timeline.hidden = false;
1716
+ const now = bars[bars.length - 1].to;
1717
+ const win = state.timeWindow;
1718
+ const live = /* @__PURE__ */ new Set();
1719
+ const frag = document.createDocumentFragment();
1720
+ for (let i = 0; i < bars.length; i++) {
1721
+ const b = bars[i];
1722
+ live.add(b.key);
1723
+ let node = barEls.get(b.key);
1724
+ if (!node) {
1725
+ node = el("button", { class: "bar", type: "button", "data-commit": String(b.key) }, [el("span", { class: "avoid" })]);
1726
+ barEls.set(b.key, node);
1727
+ }
1728
+ node.setAttribute("data-index", String(i));
1729
+ node.style.height = `${Math.max(MIN_BAR_PCT, Math.round(100 * Math.sqrt(b.total / max)))}%`;
1730
+ node.firstElementChild.style.height = `${Math.round(100 * b.avoidable / b.total)}%`;
1731
+ node.classList.toggle("has-avoid", b.avoidable > 0);
1732
+ node.classList.toggle("selected", state.selectedCommit === b.key && state.tab === "commit");
1733
+ node.classList.toggle("in-window", !!win && b.to >= win.from && b.from <= win.to);
1734
+ node.setAttribute("aria-label", `commit ${b.key}, ${plural(b.total, "render")}, ${b.avoidable} avoidable, ${spokenAgo(Math.max(0, now - b.to))}`);
1735
+ node.title = `#${b.key} \xB7 ${plural(b.total, "render")} \xB7 ${b.avoidable} avoidable`;
1736
+ frag.append(node);
1737
+ }
1738
+ stripBars.textContent = "";
1739
+ stripBars.append(frag);
1740
+ for (const key of [...barEls.keys()]) if (!live.has(key)) barEls.delete(key);
1741
+ const span = now - bars[0].from;
1742
+ stripFrom.textContent = span > 0 ? `-${fmtSpan(span)}` : "0ms";
1743
+ stripTo.textContent = "now";
1744
+ stripCount.textContent = plural(bars.length, "commit") + (order.length > bars.length ? ` of ${order.length}` : "");
1745
+ if (win) {
1746
+ let n = 0;
1747
+ for (const r of state.reports) if (inWindow(r)) n++;
1748
+ stripChip.textContent = `${fmtSpan(win.to - win.from)} window, ${plural(n, "report")} \xB7 Clear`;
1749
+ stripChip.hidden = false;
1750
+ } else stripChip.hidden = true;
1751
+ }
1752
+ function toggleStrip(value) {
1753
+ state.stripCollapsed = value === void 0 ? !state.stripCollapsed : value;
1754
+ timeline.classList.toggle("collapsed", state.stripCollapsed);
1755
+ stripChevron.textContent = state.stripCollapsed ? "\u25B8" : "\u25BE";
1756
+ if (value === void 0) persist();
1757
+ }
1758
+ function setWindow(from, to) {
1759
+ state.timeWindow = from <= to ? { from, to } : { from: to, to: from };
1760
+ afterWindowChange();
1761
+ }
1762
+ function clearWindow() {
1763
+ if (!state.timeWindow) return;
1764
+ state.timeWindow = null;
1765
+ afterWindowChange();
1766
+ }
1767
+ function afterWindowChange() {
1768
+ renderLeft();
1769
+ renderStream();
1770
+ renderDetails();
1771
+ renderStrip();
1772
+ }
1773
+ const barAt = (target) => {
1774
+ const node = target && typeof target.closest === "function" ? target.closest(".bar") : null;
1775
+ const i = node ? Number(node.getAttribute("data-index")) : -1;
1776
+ return Number.isInteger(i) && i >= 0 && i < bars.length ? i : -1;
1777
+ };
1778
+ let brush = null;
1779
+ let swallowClick = false;
1780
+ const paintBrush = () => {
1781
+ const lo = brush ? Math.min(brush.a, brush.b) : -1;
1782
+ const hi = brush ? Math.max(brush.a, brush.b) : -2;
1783
+ const nodes = barNodes();
1784
+ for (let i = 0; i < nodes.length; i++) nodes[i].classList.toggle("brushing", i >= lo && i <= hi);
1785
+ };
1786
+ stripBars.addEventListener("mousedown", (e) => {
1787
+ swallowClick = false;
1788
+ const i = barAt(e.target);
1789
+ if (i < 0) return;
1790
+ brush = { a: i, b: i };
1791
+ });
1792
+ stripBars.addEventListener("mousemove", (e) => {
1793
+ if (!brush) return;
1794
+ const i = barAt(e.target);
1795
+ if (i < 0 || i === brush.b) return;
1796
+ brush.b = i;
1797
+ paintBrush();
1798
+ });
1799
+ window.addEventListener("mouseup", (e) => {
1800
+ if (!brush) return;
1801
+ const i = barAt(e.target);
1802
+ if (i >= 0) brush.b = i;
1803
+ const { a, b } = brush;
1804
+ brush = null;
1805
+ paintBrush();
1806
+ if (a === b) return;
1807
+ swallowClick = true;
1808
+ setWindow(bars[Math.min(a, b)].from, bars[Math.max(a, b)].to);
1809
+ });
1810
+ stripBars.addEventListener("click", (e) => {
1811
+ if (swallowClick) {
1812
+ swallowClick = false;
1813
+ return;
1814
+ }
1815
+ const i = barAt(e.target);
1816
+ if (i >= 0) selectCommitBar(bars[i].key);
1817
+ });
1818
+ stripBars.addEventListener("keydown", (e) => {
1819
+ swallowClick = false;
1820
+ const key = e.key;
1821
+ if (key !== "ArrowLeft" && key !== "ArrowRight" && key !== "Home" && key !== "End") return;
1822
+ const nodes = barNodes();
1823
+ const i = nodes.indexOf(document.activeElement);
1824
+ if (i < 0) return;
1825
+ e.preventDefault();
1826
+ const to = key === "ArrowLeft" ? Math.max(0, i - 1) : key === "ArrowRight" ? Math.min(nodes.length - 1, i + 1) : key === "Home" ? 0 : nodes.length - 1;
1827
+ nodes[to].focus();
1828
+ });
1829
+ function selectCommitBar(key) {
1830
+ showCommit(key);
1831
+ if (state.view !== "commits") setView("commits");
1832
+ renderStrip();
1833
+ }
1621
1834
  function renderLeft() {
1622
1835
  if (state.view === "tree") renderTree();
1623
1836
  else if (state.view === "offenders") renderOffenders();
@@ -1627,10 +1840,73 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1627
1840
  }
1628
1841
  const rowEls = /* @__PURE__ */ new Map();
1629
1842
  const treeList = virtualList(tree, ROW_H, ({ node, depth }) => rowFor(node, depth));
1630
- const emptyEl = el("div", { class: "empty" }, [
1631
- el("div", { text: "No re-renders reported yet." }),
1632
- el("div", null, ["Call ", el("code", { text: "init({ notifier: createDevtoolsNotifier() })" }), " in the page, or enable injection in Settings, then interact with it."])
1633
- ]);
1843
+ const emptyEl = el("div", { class: "empty" });
1844
+ function trackingSentence(t) {
1845
+ const what = t.mode === "all" ? "Tracking every component" : t.mode === "memoized" ? "Tracking every React.memo and PureComponent" : "Tracking only components marked with track()";
1846
+ const parts = [what];
1847
+ if (t.include.length) parts.push(`${t.mode === "marked" ? "and" : "plus"} names matching ${t.include.join(", ")}`);
1848
+ if (t.exclude.length) parts.push(`except ${t.exclude.join(", ")}`);
1849
+ const rendered = `${t.renderedCount}${t.overflow ? "+" : ""} component${t.renderedCount === 1 ? "" : "s"} rendered`;
1850
+ return `${parts.join(", ")}; ${rendered}, ${t.trackedCount} of them tracked.`;
1851
+ }
1852
+ function renderEmpty() {
1853
+ emptyEl.textContent = "";
1854
+ emptyEl.append(el("div", { class: "empty-head", text: "No re-renders reported yet." }));
1855
+ const t = state.library?.tracking;
1856
+ if (!t) {
1857
+ emptyEl.append(
1858
+ el("div", null, ["Call ", el("code", { text: "init({ notifier: createDevtoolsNotifier() })" }), " in the page, or enable injection in Settings, then interact with it."])
1859
+ );
1860
+ return;
1861
+ }
1862
+ emptyEl.append(el("div", { class: "empty-tracking", text: trackingSentence(t) }));
1863
+ if (t.mode === "all") {
1864
+ emptyEl.append(el("div", { text: "Every component is tracked, so this is waiting for a re-render: interact with the page. The first render of a component is never reported." }));
1865
+ return;
1866
+ }
1867
+ const untracked = t.renderedCount - t.trackedCount;
1868
+ emptyEl.append(
1869
+ el("div", {
1870
+ text: untracked > 0 ? `${untracked} component${untracked === 1 ? "" : "s"} rendered without being tracked. Widen what is tracked, or interact with the page if the tracked ones simply have not re-rendered yet.` : "Interact with the page: the first render of a component is never reported, only re-renders."
1871
+ })
1872
+ );
1873
+ if (!transport.configure) return;
1874
+ const trackAll = el("button", { class: "primary", onclick: () => void applyTracking({ trackAllComponents: true }) }, "Track every component");
1875
+ const match = el("input", { class: "empty-match", type: "text", placeholder: "Name or /regex/", "aria-label": "Track components matching" });
1876
+ const addMatch = () => {
1877
+ const value = match.value.trim();
1878
+ if (!value) return;
1879
+ const include = (state.library?.tracking?.include || []).slice();
1880
+ if (!include.includes(value)) include.push(value);
1881
+ match.value = "";
1882
+ void applyTracking({ include });
1883
+ };
1884
+ match.addEventListener("keydown", (e) => {
1885
+ if (e.key === "Enter") addMatch();
1886
+ });
1887
+ emptyEl.append(
1888
+ el("div", { class: "empty-actions" }, [trackAll, match, el("button", { onclick: addMatch }, "Track components matching\u2026")])
1889
+ );
1890
+ }
1891
+ async function applyTracking(patch) {
1892
+ if (!transport.configure) return;
1893
+ try {
1894
+ const applied = await transport.configure(patch);
1895
+ if (state.library) {
1896
+ if (applied) state.library.options = applied;
1897
+ const t = state.library.tracking;
1898
+ if (t) {
1899
+ if (patch.trackAllComponents) t.mode = "all";
1900
+ if (patch.include) t.include = patch.include;
1901
+ }
1902
+ transport.storage?.set("settings", Object.assign({}, state.library.options));
1903
+ }
1904
+ toast("Applied");
1905
+ renderTree();
1906
+ } catch (e) {
1907
+ toast(`Failed: ${e.message}`);
1908
+ }
1909
+ }
1634
1910
  function renderTree() {
1635
1911
  const flat = [];
1636
1912
  const walk = (node, depth) => {
@@ -1642,6 +1918,7 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1642
1918
  };
1643
1919
  walk(state.tree, 0);
1644
1920
  if (flat.length === 0) {
1921
+ renderEmpty();
1645
1922
  if (!emptyEl.parentNode) tree.append(emptyEl);
1646
1923
  } else emptyEl.remove();
1647
1924
  if (rowEls.size > flat.length * 2 + 64) {
@@ -1698,8 +1975,9 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1698
1975
  chevron.textContent = node.expanded ? "\u25BE" : "\u25B8";
1699
1976
  const badges = row.querySelector(".badges");
1700
1977
  badges.textContent = "";
1701
- if (node.avoidable) badges.append(el("span", { class: "badge avoid", title: "avoidable re-renders", text: String(node.avoidable) }));
1702
- if (node.total) badges.append(el("span", { class: "badge", title: "re-renders", text: String(node.total) }));
1978
+ const counts = nodeCounts(node);
1979
+ if (counts.avoidable) badges.append(el("span", { class: "badge avoid", title: "avoidable re-renders", text: String(counts.avoidable) }));
1980
+ if (counts.total) badges.append(el("span", { class: "badge", title: "re-renders", text: String(counts.total) }));
1703
1981
  if (node.flash) {
1704
1982
  node.flash = false;
1705
1983
  if (Date.now() - (node.flashAt || 0) < 1e3) {
@@ -1754,7 +2032,11 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1754
2032
  toggleSettings(false);
1755
2033
  return;
1756
2034
  }
1757
- if (inField) target.blur();
2035
+ if (inField) {
2036
+ target.blur();
2037
+ return;
2038
+ }
2039
+ clearWindow();
1758
2040
  return;
1759
2041
  }
1760
2042
  if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
@@ -1862,6 +2144,7 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
1862
2144
  state.tab = "commit";
1863
2145
  renderLeft();
1864
2146
  renderDetails();
2147
+ renderStrip();
1865
2148
  }
1866
2149
  function showRoot(name) {
1867
2150
  state.selectedRoot = name;
@@ -2438,6 +2721,16 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
2438
2721
  status.querySelector(".status-text").textContent = text;
2439
2722
  tabChip.hidden = !state.tabLabel;
2440
2723
  tabChip.textContent = state.tabLabel || "";
2724
+ appPicker.hidden = state.apps.length < 2;
2725
+ if (!appPicker.hidden) {
2726
+ const want = state.apps.map((a) => `${a.id}:${a.label}`).join("|");
2727
+ if (appPicker.dataset.apps !== want) {
2728
+ appPicker.dataset.apps = want;
2729
+ appPicker.textContent = "";
2730
+ for (const a of state.apps) appPicker.append(el("option", { value: a.id, text: a.label || a.id }));
2731
+ }
2732
+ if (state.appId) appPicker.value = state.appId;
2733
+ }
2441
2734
  banner.textContent = "";
2442
2735
  const warnings = [];
2443
2736
  if (lib && typeof lib.protocol === "number" && lib.protocol > PROTOCOL) warnings.push(`The page runs a newer rerender-lens (protocol ${lib.protocol}) than this panel (${PROTOCOL}). Update the extension.`);
@@ -2457,6 +2750,7 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
2457
2750
  function setLibrary(info) {
2458
2751
  state.library = info;
2459
2752
  renderStatus();
2753
+ if (emptyEl.parentNode) renderEmpty();
2460
2754
  if (state.settingsOpen) void renderSettings();
2461
2755
  if (state.flashOn) transport.flashAvoidable?.(true);
2462
2756
  }
@@ -2634,6 +2928,13 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
2634
2928
  state.library = null;
2635
2929
  renderStatus();
2636
2930
  break;
2931
+ case "apps": {
2932
+ const p = isRecord(message.payload) ? message.payload : {};
2933
+ state.apps = Array.isArray(p.list) ? p.list : [];
2934
+ state.appId = typeof p.selected === "string" ? p.selected : null;
2935
+ renderStatus();
2936
+ break;
2937
+ }
2637
2938
  case "hello":
2638
2939
  if (isRecord(message.payload)) setLibrary(message.payload);
2639
2940
  break;
@@ -3251,6 +3552,8 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
3251
3552
  function floodReports(n) {
3252
3553
  const out = [];
3253
3554
  const components = Math.max(10, Math.floor(n / 10));
3555
+ const spread = 9e4;
3556
+ const t0 = Date.now() - spread;
3254
3557
  for (let i = 0; i < n; i++) {
3255
3558
  const id = i % components;
3256
3559
  const depth = 1 + id % 6;
@@ -3258,6 +3561,7 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
3258
3561
  for (let d = 1; d < depth; d++) path.push(`Section${(id * 7 + d) % 40}`);
3259
3562
  const avoidable = id % 3 !== 0;
3260
3563
  out.push({
3564
+ receivedAt: t0 + Math.round(i / Math.max(1, n - 1) * spread),
3261
3565
  component: `Item${id}`,
3262
3566
  path,
3263
3567
  trigger: avoidable ? "parent" : "props",
@@ -3327,14 +3631,22 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
3327
3631
  return transport;
3328
3632
  }
3329
3633
  function createRelayClientTransport(relayUrl, ES = EventSource) {
3330
- const base = relayUrl.replace(/\/$/, "");
3634
+ const [rawBase, relayQuery = ""] = relayUrl.split("?");
3635
+ const base = (rawBase ?? "").replace(/\/$/, "");
3636
+ const relayToken = new URLSearchParams(relayQuery).get("token");
3637
+ const auth = relayToken ? `token=${encodeURIComponent(relayToken)}` : "";
3331
3638
  let listener = null;
3332
3639
  let stream = null;
3333
3640
  let appsOnline = null;
3641
+ let roster = [];
3642
+ let selected = null;
3334
3643
  const emit = (m) => {
3335
3644
  if (listener) listener(m);
3336
3645
  };
3337
- const post = (message) => fetch(`${base}/message`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(message) }).then(() => void 0);
3646
+ const post = (message) => {
3647
+ const body = selected && isRecord(message) && message.__rerenderLensCmd === true ? { ...message, app: selected } : message;
3648
+ return fetch(`${base}/message${auth ? `?${auth}` : ""}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(() => void 0);
3649
+ };
3338
3650
  const { bridge, reply } = commandBridge(post, 2e3);
3339
3651
  const handleData = (data) => {
3340
3652
  let parsed;
@@ -3346,12 +3658,21 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
3346
3658
  for (const m of Array.isArray(parsed) ? parsed : [parsed]) {
3347
3659
  if (!isRecord(m) || reply(m)) continue;
3348
3660
  if (m.__rerenderLens === true && m.type === "relay") {
3349
- const apps = isRecord(m.payload) && typeof m.payload.apps === "number" ? m.payload.apps : 0;
3661
+ const p = isRecord(m.payload) ? m.payload : {};
3662
+ const apps = typeof p.apps === "number" ? p.apps : 0;
3663
+ roster = Array.isArray(p.list) ? p.list : [];
3350
3664
  const wasOnline = appsOnline;
3665
+ const wasSelected = selected;
3351
3666
  appsOnline = apps;
3352
- if (apps > 0 && (wasOnline === null || wasOnline === 0)) void attach();
3667
+ if (!roster.length) selected = null;
3668
+ else if (!selected || !roster.some((a) => a.id === selected)) selected = roster[0].id;
3669
+ emit({ type: "apps", payload: { list: roster, selected } });
3670
+ if (apps > 0 && (wasOnline === null || wasOnline === 0 || selected !== wasSelected)) void attach();
3353
3671
  else if (apps === 0) emit({ type: "disconnected" });
3354
- } else if (m.__rerenderLens === true && typeof m.type === "string") emit({ type: m.type, version: typeof m.version === "number" ? m.version : void 0, payload: m.payload });
3672
+ } else if (m.__rerenderLens === true && typeof m.type === "string") {
3673
+ if (typeof m.app === "string" && selected && m.app !== selected) continue;
3674
+ emit({ type: m.type, version: typeof m.version === "number" ? m.version : void 0, payload: m.payload });
3675
+ }
3355
3676
  }
3356
3677
  };
3357
3678
  async function attach() {
@@ -3368,10 +3689,17 @@ setState((prev) => (deepEqual(prev, next) ? prev : next));`
3368
3689
  const transport = {
3369
3690
  origin: base,
3370
3691
  tabLabel: `relay ${base.replace(/^https?:\/\//, "")}`,
3692
+ selectApp(id) {
3693
+ if (id === selected || !roster.some((a) => a.id === id)) return;
3694
+ selected = id;
3695
+ emit({ type: "apps", payload: { list: roster, selected } });
3696
+ emit({ type: "navigated" });
3697
+ void attach();
3698
+ },
3371
3699
  subscribe(fn) {
3372
3700
  listener = fn;
3373
3701
  const open = () => {
3374
- stream = new ES(`${base}/events?role=panel`);
3702
+ stream = new ES(`${base}/events?role=panel${auth ? `&${auth}` : ""}`);
3375
3703
  stream.onmessage = (e) => handleData(e.data);
3376
3704
  stream.onerror = () => {
3377
3705
  emit({ type: "disconnected" });