@buoy-gg/highlight-updates 7.0.21 → 7.0.23

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 (26) hide show
  1. package/lib/commonjs/highlight-updates/components/IsolatedRenderList.js +10 -7
  2. package/lib/commonjs/highlight-updates/components/RenderDetailView.js +9 -9
  3. package/lib/commonjs/highlight-updates/components/RenderHistoryViewer.js +15 -18
  4. package/lib/commonjs/highlight-updates/utils/CommitProfiler.js +170 -8
  5. package/lib/commonjs/highlight-updates/utils/HighlightUpdatesController.js +6 -1
  6. package/lib/commonjs/highlight-updates/utils/devtoolsComponentNames.js +46 -5
  7. package/lib/commonjs/sync/highlightUpdatesSyncAdapter.js +43 -1
  8. package/lib/module/highlight-updates/components/IsolatedRenderList.js +10 -8
  9. package/lib/module/highlight-updates/components/RenderDetailView.js +10 -10
  10. package/lib/module/highlight-updates/components/RenderHistoryViewer.js +16 -20
  11. package/lib/module/highlight-updates/utils/CommitProfiler.js +171 -9
  12. package/lib/module/highlight-updates/utils/HighlightUpdatesController.js +7 -2
  13. package/lib/module/highlight-updates/utils/devtoolsComponentNames.js +44 -4
  14. package/lib/module/sync/highlightUpdatesSyncAdapter.js +43 -1
  15. package/lib/typescript/highlight-updates/components/IsolatedRenderList.d.ts.map +1 -1
  16. package/lib/typescript/highlight-updates/components/RenderDetailView.d.ts.map +1 -1
  17. package/lib/typescript/highlight-updates/components/RenderHistoryViewer.d.ts +1 -1
  18. package/lib/typescript/highlight-updates/components/RenderHistoryViewer.d.ts.map +1 -1
  19. package/lib/typescript/highlight-updates/utils/CommitProfiler.d.ts +12 -2
  20. package/lib/typescript/highlight-updates/utils/CommitProfiler.d.ts.map +1 -1
  21. package/lib/typescript/highlight-updates/utils/HighlightUpdatesController.d.ts.map +1 -1
  22. package/lib/typescript/highlight-updates/utils/devtoolsComponentNames.d.ts +34 -0
  23. package/lib/typescript/highlight-updates/utils/devtoolsComponentNames.d.ts.map +1 -1
  24. package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts +8 -0
  25. package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts.map +1 -1
  26. package/package.json +8 -8
@@ -23,7 +23,7 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
23
23
  */
24
24
 
25
25
  // Free tier limit for tracked components
26
- const FREE_TIER_COMPONENT_LIMIT = 25;
26
+
27
27
  /**
28
28
  * Isolated list component that owns the render subscription.
29
29
  * Parent component does NOT re-render when renders change.
@@ -62,16 +62,19 @@ function IsolatedRenderListInner({
62
62
  }, [searchText, filters, onRendersChange]);
63
63
 
64
64
  // Limit visible components for free tier
65
+ // `isPro` stays a prop so callers can force the unlimited view; the tier cap
66
+ // applies otherwise. useLimit() already returns Infinity for Pro, so the two
67
+ // agree — this only preserves the prop contract for cross-package callers.
68
+ const tierLimit = (0, _sharedUi.useLimit)("highlightUpdates.components");
69
+ const componentLimit = isPro ? Infinity : tierLimit;
65
70
  const visibleRenders = (0, _react.useMemo)(() => {
66
- if (isPro) return renders;
67
- return renders.slice(0, FREE_TIER_COMPONENT_LIMIT);
68
- }, [renders, isPro]);
71
+ return renders.slice(0, componentLimit);
72
+ }, [renders, componentLimit]);
69
73
 
70
74
  // Calculate locked component count
71
75
  const lockedComponentCount = (0, _react.useMemo)(() => {
72
- if (isPro) return 0;
73
- return Math.max(0, renders.length - FREE_TIER_COMPONENT_LIMIT);
74
- }, [renders.length, isPro]);
76
+ return Math.max(0, renders.length - componentLimit);
77
+ }, [renders.length, componentLimit]);
75
78
 
76
79
  // Stable keyExtractor - moved outside to avoid recreation
77
80
  const keyExtractor = (0, _react.useCallback)(item => item.id, []);
@@ -26,7 +26,6 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
26
26
  */
27
27
 
28
28
  // Free tier limit for render history events
29
- const FREE_TIER_EVENT_LIMIT = 5;
30
29
  function RenderDetailView({
31
30
  render,
32
31
  disableInternalFooter = false,
@@ -52,11 +51,12 @@ function RenderDetailView({
52
51
  const totalEvents = events.length;
53
52
  const currentEvent = events[selectedEventIndex];
54
53
 
55
- // Calculate hidden events for free tier
56
- const hiddenEventCount = (0, _react.useMemo)(() => {
57
- if (isPro) return 0;
58
- return Math.max(0, totalEvents - FREE_TIER_EVENT_LIMIT);
59
- }, [isPro, totalEvents]);
54
+ // `isPro` stays a prop so callers can force the unlimited view; the tier cap
55
+ // applies otherwise. useLimit() already returns Infinity for Pro, so the two
56
+ // agree — this only preserves the prop contract for cross-package callers.
57
+ const tierLimit = (0, _sharedUi.useLimit)("highlightUpdates.detailEvents");
58
+ const eventLimit = isPro ? Infinity : tierLimit;
59
+ const hiddenEventCount = (0, _react.useMemo)(() => Math.max(0, totalEvents - eventLimit), [eventLimit, totalEvents]);
60
60
 
61
61
  // Use current event's cause if available, otherwise fall back to lastRenderCause
62
62
  const displayCause = currentEvent?.cause || render.lastRenderCause;
@@ -68,12 +68,12 @@ function RenderDetailView({
68
68
  }
69
69
  }, [selectedEventIndex, onEventIndexChange]);
70
70
  const goToNext = (0, _react.useCallback)(() => {
71
- // Limit navigation for free tier users
72
- const maxIndex = isPro ? totalEvents - 1 : Math.min(FREE_TIER_EVENT_LIMIT - 1, totalEvents - 1);
71
+ // Infinity - 1 is Infinity, so an unlimited tier just gets totalEvents - 1.
72
+ const maxIndex = Math.min(eventLimit - 1, totalEvents - 1);
73
73
  if (selectedEventIndex < maxIndex) {
74
74
  onEventIndexChange(selectedEventIndex + 1);
75
75
  }
76
- }, [selectedEventIndex, totalEvents, onEventIndexChange, isPro]);
76
+ }, [selectedEventIndex, totalEvents, onEventIndexChange, eventLimit]);
77
77
 
78
78
  // Memoize copy data
79
79
  const copyData = (0, _react.useMemo)(() => (0, _rendersExportFormatter.generateSingleComponentExport)(render), [render]);
@@ -21,8 +21,6 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
21
21
  * Shows current state view or diff view between renders.
22
22
  */
23
23
 
24
- // Free tier limit for render history events
25
- const FREE_TIER_EVENT_LIMIT = 3;
26
24
  /**
27
25
  * Format timestamp with milliseconds
28
26
  */
@@ -63,18 +61,16 @@ function RenderHistoryViewer({
63
61
  return [...render.renderHistory].sort((a, b) => a.timestamp - b.timestamp);
64
62
  }, [render.renderHistory]);
65
63
 
66
- // Limit visible events for free tier (show most recent N events)
67
- const events = (0, _react.useMemo)(() => {
68
- if (isPro) return allEvents;
69
- // Take the most recent events (last N in the sorted list)
70
- return allEvents.slice(-FREE_TIER_EVENT_LIMIT);
71
- }, [allEvents, isPro]);
64
+ // `isPro` stays a prop so callers can force the unlimited view; the tier cap
65
+ // applies otherwise. useLimit() already returns Infinity for Pro, so the two
66
+ // agree — this only preserves the prop contract for cross-package callers.
67
+ const tierLimit = (0, _sharedUi.useLimit)("highlightUpdates.historyEvents");
68
+ const eventLimit = isPro ? Infinity : tierLimit;
72
69
 
73
- // Calculate how many events are locked
74
- const lockedEventCount = (0, _react.useMemo)(() => {
75
- if (isPro) return 0;
76
- return Math.max(0, allEvents.length - FREE_TIER_EVENT_LIMIT);
77
- }, [allEvents.length, isPro]);
70
+ // Show the most recent N events (negative index clamps to 0, so an
71
+ // unlimited tier returns the whole list).
72
+ const events = (0, _react.useMemo)(() => allEvents.slice(-eventLimit), [allEvents, eventLimit]);
73
+ const lockedEventCount = (0, _react.useMemo)(() => Math.max(0, allEvents.length - eventLimit), [allEvents.length, eventLimit]);
78
74
  const totalEvents = events.length;
79
75
  const currentEvent = events[selectedEventIndex];
80
76
  const previousEvent = selectedEventIndex > 0 ? events[selectedEventIndex - 1] : null;
@@ -470,11 +466,12 @@ function RenderHistoryFooter({
470
466
  return [...render.renderHistory].sort((a, b) => a.timestamp - b.timestamp);
471
467
  }, [render.renderHistory]);
472
468
 
473
- // Limit visible events for free tier (show most recent N events)
474
- const events = (0, _react.useMemo)(() => {
475
- if (isPro) return allEvents;
476
- return allEvents.slice(-FREE_TIER_EVENT_LIMIT);
477
- }, [allEvents, isPro]);
469
+ // Same cap as RenderHistoryViewer above; `isPro` remains a caller override.
470
+ const tierLimit = (0, _sharedUi.useLimit)("highlightUpdates.historyEvents");
471
+ const eventLimit = isPro ? Infinity : tierLimit;
472
+
473
+ // Most recent N (negative index clamps to 0, so unlimited returns all).
474
+ const events = (0, _react.useMemo)(() => allEvents.slice(-eventLimit), [allEvents, eventLimit]);
478
475
  const totalEvents = events.length;
479
476
  const currentEvent = events[selectedEventIndex];
480
477
  const goToPrevious = (0, _react.useCallback)(() => {
@@ -45,9 +45,11 @@ const TOP_COMPONENTS_CAP = 20;
45
45
  let hookInstalled = false;
46
46
  let active = false;
47
47
  let sawDurations = false;
48
+ let captureDetail = false;
48
49
  let totalCommits = 0;
49
50
  let totalRenders = 0;
50
51
  let totalRenderMs = 0;
52
+ let totalCauses = {};
51
53
  let buckets = new Map();
52
54
  function getHook() {
53
55
  if (typeof global === "undefined") return null;
@@ -119,13 +121,129 @@ function isFrameworkWrapperName(name) {
119
121
  }
120
122
  return false;
121
123
  }
122
- function recordRender(fiber) {
124
+
125
+ /** Bound on prop keys compared per render, so a fat props object can't stall a commit. */
126
+ const MAX_PROPS_COMPARED = 40;
127
+ /** Bound on distinct changed-prop key names remembered per component (detail tier). */
128
+ const MAX_CHANGED_KEYS = 5;
129
+
130
+ /**
131
+ * Are two values for the same prop key equivalent despite differing identity?
132
+ *
133
+ * Functions always count as equivalent: a fresh arrow per render is THE
134
+ * unstable-prop bug, and there's no way to compare behaviour anyway. Plain
135
+ * objects and arrays compare one level deep, which catches `style={{...}}`
136
+ * and `data={[...]}`. Anything else falls through to a real value change.
137
+ */
138
+ function isEquivalentValue(a, b) {
139
+ if (Object.is(a, b)) return true;
140
+ const ta = typeof a;
141
+ if (ta !== typeof b) return false;
142
+ if (ta === "function") return true;
143
+ if (a === null || b === null || ta !== "object") return false;
144
+ const aIsArray = Array.isArray(a);
145
+ if (aIsArray !== Array.isArray(b)) return false;
146
+ if (aIsArray) {
147
+ if (a.length !== b.length) return false;
148
+ for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false;
149
+ return true;
150
+ }
151
+ const keysA = Object.keys(a);
152
+ const keysB = Object.keys(b);
153
+ if (keysA.length !== keysB.length) return false;
154
+ for (let i = 0; i < keysA.length; i++) {
155
+ const k = keysA[i];
156
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
157
+ if (!Object.is(a[k], b[k])) return false;
158
+ }
159
+ return true;
160
+ }
161
+ /**
162
+ * Why did this fiber render?
163
+ *
164
+ * Simpler than the overlay's RenderCauseDetector, and deliberately so: that
165
+ * one carries WeakMap fallbacks and fiber-swap detection because traceUpdates
166
+ * hands it fibers at an ambiguous moment. Here we're inside onCommitFiberRoot
167
+ * walking the committed tree, so `fiber` is current and `fiber.alternate` is
168
+ * previous by definition. Porting the swap logic would be cargo-cult.
169
+ */
170
+ function detectCause(fiber, wantKeys) {
171
+ const prev = fiber.alternate;
172
+ if (!prev) return {
173
+ cause: "mount"
174
+ };
175
+ const nextProps = fiber.memoizedProps;
176
+ const prevProps = prev.memoizedProps;
177
+ if (nextProps !== prevProps && nextProps && prevProps) {
178
+ let sawChange = false;
179
+ let sawValueChange = false;
180
+ let changedKeys;
181
+ const keys = Object.keys(nextProps);
182
+ const limit = keys.length < MAX_PROPS_COMPARED ? keys.length : MAX_PROPS_COMPARED;
183
+ for (let i = 0; i < limit; i++) {
184
+ const k = keys[i];
185
+ const a = prevProps[k];
186
+ const b = nextProps[k];
187
+ if (Object.is(a, b)) continue;
188
+ sawChange = true;
189
+ if (wantKeys) {
190
+ if (!changedKeys) changedKeys = [];
191
+ if (changedKeys.length < MAX_CHANGED_KEYS) changedKeys.push(k);
192
+ }
193
+ if (!isEquivalentValue(a, b)) {
194
+ sawValueChange = true;
195
+ if (!wantKeys) break; // cause is settled; only keys need the full pass
196
+ }
197
+ }
198
+ // A removed key counts as a real change; cheap to detect via key count.
199
+ if (!sawValueChange && Object.keys(prevProps).length !== keys.length) {
200
+ sawChange = true;
201
+ sawValueChange = true;
202
+ }
203
+ if (sawChange) {
204
+ return {
205
+ cause: sawValueChange ? "props" : "propsUnstable",
206
+ changedKeys
207
+ };
208
+ }
209
+ }
210
+
211
+ // No prop change of substance. Own state/hooks next.
212
+ if (prev.memoizedState !== fiber.memoizedState) return {
213
+ cause: "hooks"
214
+ };
215
+ return {
216
+ cause: "parent"
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Nearest ancestor that ALSO rendered this commit and is worth naming — i.e.
222
+ * skipping framework wrappers, which are never where a fix goes. Answers "who
223
+ * dragged this along", which is the actionable half of a cascade.
224
+ */
225
+ function nearestRenderedAncestorName(fiber) {
226
+ for (let p = fiber.return; p != null; p = p.return) {
227
+ if (!isCompositeTag(p.tag)) continue;
228
+ if ((getFlags(p) & PerformedWork) === 0) continue;
229
+ const name = getComponentName(p);
230
+ if (isFrameworkWrapperName(name)) continue;
231
+ return name;
232
+ }
233
+ return undefined;
234
+ }
235
+ function recordRender(fiber, parentName) {
123
236
  const name = getComponentName(fiber);
124
237
  const selfMs = computeSelfMs(fiber);
125
238
  if (selfMs !== null) sawDurations = true;
126
239
  const ms = selfMs ?? 0;
127
240
  totalRenders += 1;
128
241
  totalRenderMs += ms;
242
+ const {
243
+ cause,
244
+ changedKeys
245
+ } = detectCause(fiber, captureDetail);
246
+ totalCauses[cause] = (totalCauses[cause] ?? 0) + 1;
129
247
 
130
248
  // Framework wrappers count toward totals but never surface as a row.
131
249
  if (isFrameworkWrapperName(name)) return;
@@ -136,13 +254,27 @@ function recordRender(fiber) {
136
254
  bucket = {
137
255
  renders: 0,
138
256
  totalSelfMs: 0,
139
- maxSelfMs: 0
257
+ maxSelfMs: 0,
258
+ causes: {}
140
259
  };
141
260
  buckets.set(name, bucket);
142
261
  }
143
262
  bucket.renders += 1;
144
263
  bucket.totalSelfMs += ms;
145
264
  if (ms > bucket.maxSelfMs) bucket.maxSelfMs = ms;
265
+ bucket.causes[cause] = (bucket.causes[cause] ?? 0) + 1;
266
+ if (captureDetail) {
267
+ if (changedKeys) {
268
+ if (!bucket.changedProps) bucket.changedProps = new Set();
269
+ for (let i = 0; i < changedKeys.length; i++) {
270
+ if (bucket.changedProps.size >= MAX_CHANGED_KEYS) break;
271
+ bucket.changedProps.add(changedKeys[i]);
272
+ }
273
+ }
274
+ // First parent wins — a component rendered under several parents is rare,
275
+ // and the first is the one that showed up in the heaviest commit.
276
+ if (parentName && !bucket.parentName) bucket.parentName = parentName;
277
+ }
146
278
  }
147
279
 
148
280
  /**
@@ -161,13 +293,21 @@ function handleCommit(root) {
161
293
  let skipSubtree = false;
162
294
  const composite = isCompositeTag(fiber.tag);
163
295
  if (composite) {
164
- // Prune Buoy's own devtools UI wholesale.
165
- if ((0, _devtoolsComponentNames.isDevToolsComponentName)(getComponentName(fiber))) {
296
+ const compositeName = getComponentName(fiber);
297
+ // Only Buoy's render-tracking UI gets its SUBTREE pruned — walking it
298
+ // feeds its own renders back in and loops. Everything else that's merely
299
+ // devtools noise is skipped as a NODE and we keep descending: app
300
+ // components live underneath Buoy chrome, and pruning a subtree on a name
301
+ // match is what made a stray "Virtualized" prefix hide every FlatList in
302
+ // every app.
303
+ if ((0, _devtoolsComponentNames.isRenderLoopUnsafeName)(compositeName)) {
166
304
  skipSubtree = true;
305
+ } else if ((0, _devtoolsComponentNames.isDevToolsComponentName)(compositeName)) {
306
+ // skip this node, keep walking its children
167
307
  } else if ((getFlags(fiber) & PerformedWork) !== 0) {
168
308
  // PerformedWork is the only trustworthy "rendered this commit"
169
309
  // signal — actualDuration is stale on bailed-out fibers.
170
- recordRender(fiber);
310
+ recordRender(fiber, captureDetail ? nearestRenderedAncestorName(fiber) : undefined);
171
311
  }
172
312
  }
173
313
  if (!skipSubtree) {
@@ -230,14 +370,24 @@ function isCapturing() {
230
370
  return active;
231
371
  }
232
372
 
233
- /** Begin a capture window, discarding any previous unclaimed data. */
234
- function startCapture() {
373
+ /**
374
+ * Begin a capture window, discarding any previous unclaimed data.
375
+ *
376
+ * `detail` turns on the allocating tier — changed prop key names and parent
377
+ * component names. Causes themselves are always classified: it costs a shallow
378
+ * prop compare, against a `computeSelfMs` that already walks every direct
379
+ * child on every render, and the wasted-render total is the number most worth
380
+ * seeing without having to know to ask for it.
381
+ */
382
+ function startCapture(opts) {
235
383
  if (!ensureHookInstalled()) return;
236
384
  buckets = new Map();
237
385
  sawDurations = false;
386
+ captureDetail = opts?.detail === true;
238
387
  totalCommits = 0;
239
388
  totalRenders = 0;
240
389
  totalRenderMs = 0;
390
+ totalCauses = {};
241
391
  active = true;
242
392
  }
243
393
 
@@ -253,19 +403,31 @@ function stopCapture() {
253
403
  renders: b.renders,
254
404
  totalMs: round2(b.totalSelfMs),
255
405
  maxMs: round2(b.maxSelfMs),
256
- avgMs: b.renders > 0 ? round2(b.totalSelfMs / b.renders) : 0
406
+ avgMs: b.renders > 0 ? round2(b.totalSelfMs / b.renders) : 0,
407
+ causes: b.causes,
408
+ ...(b.changedProps && b.changedProps.size > 0 ? {
409
+ changedProps: Array.from(b.changedProps)
410
+ } : null),
411
+ ...(b.parentName ? {
412
+ parentName: b.parentName
413
+ } : null)
257
414
  }));
258
415
  const summary = {
259
416
  totalCommits,
260
417
  totalRenders,
261
418
  capturedDurations: sawDurations,
262
419
  totalRenderMs: round2(totalRenderMs),
420
+ // Parent cascades and identity-only prop churn are both removable without
421
+ // changing behaviour — together they're the recoverable share.
422
+ wastedRenders: (totalCauses.parent ?? 0) + (totalCauses.propsUnstable ?? 0),
423
+ causes: totalCauses,
263
424
  topComponents
264
425
  };
265
426
  buckets = new Map();
266
427
  totalCommits = 0;
267
428
  totalRenders = 0;
268
429
  totalRenderMs = 0;
430
+ totalCauses = {};
269
431
  return summary;
270
432
  }
271
433
  function round2(n) {
@@ -555,7 +555,12 @@ function isOurOverlayNode(stateNode) {
555
555
 
556
556
  // Only check other dev tools if excludeDevTools is enabled
557
557
  if (excludeDevTools) {
558
- if (_devtoolsComponentNames.OTHER_DEV_TOOLS_COMPONENTS.has(name)) {
558
+ // Two sets, same meaning: COMPONENTS is the original list, EXACT
559
+ // holds the names that used to be matched by over-broad prefixes
560
+ // (the "Virtualized" that hid every FlatList, the "Dial" that hid
561
+ // every Dialog). Both paths must consult both, or Buoy's own chrome
562
+ // starts flashing highlight boxes in one of them and not the other.
563
+ if (_devtoolsComponentNames.OTHER_DEV_TOOLS_COMPONENTS.has(name) || _devtoolsComponentNames.OTHER_DEV_TOOLS_EXACT.has(name)) {
559
564
  result = true;
560
565
  break;
561
566
  }
@@ -3,11 +3,12 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.OTHER_DEV_TOOLS_PREFIXES = exports.OTHER_DEV_TOOLS_COMPONENTS = exports.HIGHLIGHT_OVERLAY_PREFIXES = exports.HIGHLIGHT_OVERLAY_COMPONENTS = void 0;
6
+ exports.OTHER_DEV_TOOLS_PREFIXES = exports.OTHER_DEV_TOOLS_EXACT = exports.OTHER_DEV_TOOLS_COMPONENTS = exports.HIGHLIGHT_OVERLAY_PREFIXES = exports.HIGHLIGHT_OVERLAY_COMPONENTS = void 0;
7
7
  exports.isCapturingBuoyToolRenders = isCapturingBuoyToolRenders;
8
8
  exports.isDevToolsComponentName = isDevToolsComponentName;
9
9
  exports.isJsTopComponentName = isJsTopComponentName;
10
10
  exports.isPerfMonitorComponentName = isPerfMonitorComponentName;
11
+ exports.isRenderLoopUnsafeName = isRenderLoopUnsafeName;
11
12
  exports.setCaptureBuoyToolRenders = setCaptureBuoyToolRenders;
12
13
  // ============================================================================
13
14
  // Buoy devtools component-name sets, shared by HighlightUpdatesController's
@@ -66,16 +67,37 @@ const OTHER_DEV_TOOLS_COMPONENTS = exports.OTHER_DEV_TOOLS_COMPONENTS = new Set(
66
67
  // separately). The modal/table re-render on every 250ms snapshot tick.
67
68
  "TaskTable", "TaskRow", "BusySparkline"]);
68
69
 
69
- // Other dev tools prefixes - can be toggled via excludeDevTools setting
70
- const OTHER_DEV_TOOLS_PREFIXES = exports.OTHER_DEV_TOOLS_PREFIXES = ["JsModal", "TreeDiff", "DataViewer", "DiffView", "DiffSummary", "Virtualized",
71
- // Floating menu prefixes
72
- "Floating", "Minimized", "Dial", "Expandable", "Chevron", "Glitch", "Settings", "Onboarding", "DevTools", "ImageOverlay",
70
+ /**
71
+ * Other dev-tools prefixes — noise reduction, toggleable via excludeDevTools.
72
+ *
73
+ * EVERY ENTRY HERE MUST BE BUOY-NAMESPACED. These are prefix matches, and a
74
+ * match hides the component from render reports. Generic English words do not
75
+ * belong in this list, because app components share them.
76
+ *
77
+ * This list used to contain `Virtualized`, meant for Buoy's own
78
+ * VirtualizedDataExplorer. React Native's FlatList renders a `VirtualizedList`,
79
+ * so the prefix matched — and because CommitProfiler pruned the whole subtree
80
+ * on a match, Buoy could not see inside ANY FlatList or SectionList in ANY
81
+ * app. Lists are the number-one source of render problems in React Native, and
82
+ * the render tools were blind to all of them. `Dial` was the same trap:
83
+ * "Dialog".startsWith("Dial") is true, so every Dialog vanished too.
84
+ *
85
+ * Names that are generic on their own now live in OTHER_DEV_TOOLS_COMPONENTS
86
+ * as exact matches instead.
87
+ */
88
+ const OTHER_DEV_TOOLS_PREFIXES = exports.OTHER_DEV_TOOLS_PREFIXES = ["JsModal", "ImageOverlay",
73
89
  // Perf-monitor prefixes (safe: user code is Perf*, buoy code is
74
90
  // PerfMonitor* / PerfHud*)
75
91
  "PerfMonitor", "PerfHud",
76
92
  // JS TOP (js-top package)
77
93
  "JsTop"];
78
94
 
95
+ /**
96
+ * Buoy component names that were previously matched by over-broad prefixes.
97
+ * Exact matches only — see the warning above.
98
+ */
99
+ const OTHER_DEV_TOOLS_EXACT = exports.OTHER_DEV_TOOLS_EXACT = new Set(["VirtualizedDataExplorer", "TreeDiffView", "TreeDiffRow", "DataViewer", "DiffView", "DiffSummary", "FloatingDevTools", "FloatingMenu", "FloatingBubble", "MinimizedDock", "DialMenu", "DialItem", "ExpandableSection", "ChevronIcon", "GlitchText", "SettingsModal", "SettingsRow", "OnboardingFlow", "DevToolsMenu"]);
100
+
79
101
  // ── Bench "profile our own tools" mode ──────────────────────────────────
80
102
  // When enabled (by a dedicated benchmark screen, e.g. the example app's
81
103
  // /tool-bench page), render capture INCLUDES Buoy's tool UI so we can
@@ -140,8 +162,27 @@ function isDevToolsComponentName(name) {
140
162
  return isPerfMonitorComponentName(name) || isJsTopComponentName(name);
141
163
  }
142
164
  if (OTHER_DEV_TOOLS_COMPONENTS.has(name)) return true;
165
+ if (OTHER_DEV_TOOLS_EXACT.has(name)) return true;
143
166
  for (let i = 0; i < OTHER_DEV_TOOLS_PREFIXES.length; i++) {
144
167
  if (name.startsWith(OTHER_DEV_TOOLS_PREFIXES[i])) return true;
145
168
  }
146
169
  return false;
170
+ }
171
+
172
+ /**
173
+ * Is this name one whose SUBTREE must be skipped, not merely the node?
174
+ *
175
+ * Only the always-excluded group qualifies: walking Buoy's own render-tracking
176
+ * UI feeds its own renders back in and loops. Everything else in
177
+ * `isDevToolsComponentName` is noise reduction — hide the row, but keep
178
+ * walking, because app components live underneath Buoy chrome (a tool modal
179
+ * renders the host app behind it) and pruning on a NAME is how one bad prefix
180
+ * cost us every FlatList in every app.
181
+ */
182
+ function isRenderLoopUnsafeName(name) {
183
+ if (HIGHLIGHT_OVERLAY_COMPONENTS.has(name)) return true;
184
+ for (let i = 0; i < HIGHLIGHT_OVERLAY_PREFIXES.length; i++) {
185
+ if (name.startsWith(HIGHLIGHT_OVERLAY_PREFIXES[i])) return true;
186
+ }
187
+ return false;
147
188
  }
@@ -7,6 +7,7 @@ exports.highlightUpdatesSyncAdapter = void 0;
7
7
  var _HighlightUpdatesController = _interopRequireDefault(require("../highlight-updates/utils/HighlightUpdatesController"));
8
8
  var _RenderTracker = require("../highlight-updates/utils/RenderTracker");
9
9
  var _screenElements = require("../highlight-updates/utils/screenElements");
10
+ var _CommitProfiler = require("../highlight-updates/utils/CommitProfiler");
10
11
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
12
  function ensureInitialized() {
12
13
  if (!_HighlightUpdatesController.default.isInitialized()) {
@@ -65,6 +66,41 @@ function jsonSafeRender(render) {
65
66
  }
66
67
  }
67
68
 
69
+ /**
70
+ * Measurement window for automated callers (MCP `measure_renders`).
71
+ *
72
+ * Backed by CommitProfiler, NOT the RenderTracker that feeds the overlay, and
73
+ * the difference is the whole point. RenderTracker tracks HOST VIEWS: ask it
74
+ * why `DealRow` re-rendered and it answers about the native `View` inside it —
75
+ * "props changed: accessibilityState, accessibilityValue" — which is true and
76
+ * useless. CommitProfiler walks COMPOSITE fibers, so it answers about the
77
+ * component you named: which of ITS props changed, whether they changed by
78
+ * value or only by identity, and which parent dragged it along.
79
+ *
80
+ * It also touches none of the user's overlay state — no enabling, no clearing
81
+ * counters someone was collecting, nothing drawn on screen. A measurement is
82
+ * invisible.
83
+ */
84
+ function beginMeasurement() {
85
+ if (!_CommitProfiler.CommitProfiler.isSupported()) {
86
+ return {
87
+ ok: false,
88
+ reason: "render capture needs a dev build with the React DevTools hook available"
89
+ };
90
+ }
91
+ _CommitProfiler.CommitProfiler.startCapture({
92
+ detail: true
93
+ });
94
+ return {
95
+ ok: true
96
+ };
97
+ }
98
+ function endMeasurement() {
99
+ return {
100
+ summary: _CommitProfiler.CommitProfiler.stopCapture()
101
+ };
102
+ }
103
+
68
104
  /**
69
105
  * Sync adapter for the highlight-updates tool, consumed by
70
106
  * @buoy-gg/external-sync's `useExternalSync` (structurally matches its
@@ -81,7 +117,9 @@ function jsonSafeRender(render) {
81
117
  * stay on the device. Dashboards fetch them via `getRenderDetail`.
82
118
  */
83
119
  const highlightUpdatesSyncAdapter = exports.highlightUpdatesSyncAdapter = {
84
- version: 2,
120
+ // v3: beginMeasurement / endMeasurement — a CommitProfiler capture window
121
+ // for automated callers, with per-component causes. Invisible to the user.
122
+ version: 3,
85
123
  getSnapshot: () => ({
86
124
  enabled: _HighlightUpdatesController.default.isEnabled(),
87
125
  frozen: _HighlightUpdatesController.default.getFrozen(),
@@ -103,6 +141,10 @@ const highlightUpdatesSyncAdapter = exports.highlightUpdatesSyncAdapter = {
103
141
  };
104
142
  },
105
143
  actions: {
144
+ // Capture window for automated callers (MCP `measure_renders`). Pairs:
145
+ // begin -> drive the UI -> end. `end` always restores.
146
+ beginMeasurement: () => beginMeasurement(),
147
+ endMeasurement: () => endMeasurement(),
106
148
  /**
107
149
  * On-demand detail: one component's real renderHistory + lastRenderCause
108
150
  * (including hookChanges). Wire-form snapshots omit those.
@@ -15,11 +15,10 @@ import React, { useState, useEffect, useRef, useCallback, memo, useMemo } from "
15
15
  import { View, Text, FlatList, StyleSheet } from "react-native";
16
16
  import { RenderTracker } from "../utils/RenderTracker";
17
17
  import { RenderListItem } from "./RenderListItem";
18
- import { Activity, Lock, buoyColors } from "@buoy-gg/shared-ui";
18
+ import { Activity, Lock, buoyColors, useLimit } from "@buoy-gg/shared-ui";
19
19
 
20
20
  // Free tier limit for tracked components
21
21
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
22
- const FREE_TIER_COMPONENT_LIMIT = 25;
23
22
  /**
24
23
  * Isolated list component that owns the render subscription.
25
24
  * Parent component does NOT re-render when renders change.
@@ -58,16 +57,19 @@ function IsolatedRenderListInner({
58
57
  }, [searchText, filters, onRendersChange]);
59
58
 
60
59
  // Limit visible components for free tier
60
+ // `isPro` stays a prop so callers can force the unlimited view; the tier cap
61
+ // applies otherwise. useLimit() already returns Infinity for Pro, so the two
62
+ // agree — this only preserves the prop contract for cross-package callers.
63
+ const tierLimit = useLimit("highlightUpdates.components");
64
+ const componentLimit = isPro ? Infinity : tierLimit;
61
65
  const visibleRenders = useMemo(() => {
62
- if (isPro) return renders;
63
- return renders.slice(0, FREE_TIER_COMPONENT_LIMIT);
64
- }, [renders, isPro]);
66
+ return renders.slice(0, componentLimit);
67
+ }, [renders, componentLimit]);
65
68
 
66
69
  // Calculate locked component count
67
70
  const lockedComponentCount = useMemo(() => {
68
- if (isPro) return 0;
69
- return Math.max(0, renders.length - FREE_TIER_COMPONENT_LIMIT);
70
- }, [renders.length, isPro]);
71
+ return Math.max(0, renders.length - componentLimit);
72
+ }, [renders.length, componentLimit]);
71
73
 
72
74
  // Stable keyExtractor - moved outside to avoid recreation
73
75
  const keyExtractor = useCallback(item => item.id, []);
@@ -14,13 +14,12 @@
14
14
 
15
15
  import React, { useMemo, useState, useCallback } from "react";
16
16
  import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from "react-native";
17
- import { CopyButton, formatRelativeTime, PlusIcon, MinusIcon, EventStepperFooter, Lock, buoyColors } from "@buoy-gg/shared-ui";
17
+ import { CopyButton, formatRelativeTime, PlusIcon, MinusIcon, EventStepperFooter, Lock, buoyColors, useLimit } from "@buoy-gg/shared-ui";
18
18
  import { TreeDiffViewer } from "@buoy-gg/shared-ui/dataViewer";
19
19
  import { CAUSE_CONFIG, COMPONENT_CAUSE_CONFIG } from "./RenderCauseBadge";
20
20
  import { generateSingleComponentExport } from "../utils/rendersExportFormatter";
21
21
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
22
22
  // Free tier limit for render history events
23
- const FREE_TIER_EVENT_LIMIT = 5;
24
23
  export function RenderDetailView({
25
24
  render,
26
25
  disableInternalFooter = false,
@@ -46,11 +45,12 @@ export function RenderDetailView({
46
45
  const totalEvents = events.length;
47
46
  const currentEvent = events[selectedEventIndex];
48
47
 
49
- // Calculate hidden events for free tier
50
- const hiddenEventCount = useMemo(() => {
51
- if (isPro) return 0;
52
- return Math.max(0, totalEvents - FREE_TIER_EVENT_LIMIT);
53
- }, [isPro, totalEvents]);
48
+ // `isPro` stays a prop so callers can force the unlimited view; the tier cap
49
+ // applies otherwise. useLimit() already returns Infinity for Pro, so the two
50
+ // agree — this only preserves the prop contract for cross-package callers.
51
+ const tierLimit = useLimit("highlightUpdates.detailEvents");
52
+ const eventLimit = isPro ? Infinity : tierLimit;
53
+ const hiddenEventCount = useMemo(() => Math.max(0, totalEvents - eventLimit), [eventLimit, totalEvents]);
54
54
 
55
55
  // Use current event's cause if available, otherwise fall back to lastRenderCause
56
56
  const displayCause = currentEvent?.cause || render.lastRenderCause;
@@ -62,12 +62,12 @@ export function RenderDetailView({
62
62
  }
63
63
  }, [selectedEventIndex, onEventIndexChange]);
64
64
  const goToNext = useCallback(() => {
65
- // Limit navigation for free tier users
66
- const maxIndex = isPro ? totalEvents - 1 : Math.min(FREE_TIER_EVENT_LIMIT - 1, totalEvents - 1);
65
+ // Infinity - 1 is Infinity, so an unlimited tier just gets totalEvents - 1.
66
+ const maxIndex = Math.min(eventLimit - 1, totalEvents - 1);
67
67
  if (selectedEventIndex < maxIndex) {
68
68
  onEventIndexChange(selectedEventIndex + 1);
69
69
  }
70
- }, [selectedEventIndex, totalEvents, onEventIndexChange, isPro]);
70
+ }, [selectedEventIndex, totalEvents, onEventIndexChange, eventLimit]);
71
71
 
72
72
  // Memoize copy data
73
73
  const copyData = useMemo(() => generateSingleComponentExport(render), [render]);