@buoy-gg/highlight-updates 5.0.0 → 6.0.1

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.
@@ -15,6 +15,12 @@ Object.defineProperty(exports, "COMPONENT_CAUSE_CONFIG", {
15
15
  return _RenderCauseBadge.COMPONENT_CAUSE_CONFIG;
16
16
  }
17
17
  });
18
+ Object.defineProperty(exports, "CommitProfiler", {
19
+ enumerable: true,
20
+ get: function () {
21
+ return _CommitProfiler.CommitProfiler;
22
+ }
23
+ });
18
24
  Object.defineProperty(exports, "HighlightFilterView", {
19
25
  enumerable: true,
20
26
  get: function () {
@@ -201,6 +207,12 @@ Object.defineProperty(exports, "installProfilerInterceptor", {
201
207
  return _ProfilerInterceptor.installProfilerInterceptor;
202
208
  }
203
209
  });
210
+ Object.defineProperty(exports, "isCapturingBuoyToolRenders", {
211
+ enumerable: true,
212
+ get: function () {
213
+ return _devtoolsComponentNames.isCapturingBuoyToolRenders;
214
+ }
215
+ });
204
216
  Object.defineProperty(exports, "isInterceptorInstalled", {
205
217
  enumerable: true,
206
218
  get: function () {
@@ -232,6 +244,12 @@ Object.defineProperty(exports, "safeCloneForHistory", {
232
244
  return _RenderCauseDetector.safeCloneForHistory;
233
245
  }
234
246
  });
247
+ Object.defineProperty(exports, "setCaptureBuoyToolRenders", {
248
+ enumerable: true,
249
+ get: function () {
250
+ return _devtoolsComponentNames.setCaptureBuoyToolRenders;
251
+ }
252
+ });
235
253
  Object.defineProperty(exports, "setComparisonCallback", {
236
254
  enumerable: true,
237
255
  get: function () {
@@ -249,6 +267,9 @@ var _HighlightUpdatesController = _interopRequireDefault(require("./highlight-up
249
267
  var _highlightUpdatesSyncAdapter = require("./sync/highlightUpdatesSyncAdapter");
250
268
  var _HighlightUpdatesOverlay = require("./highlight-updates/HighlightUpdatesOverlay");
251
269
  var _ProfilerInterceptor = require("./highlight-updates/utils/ProfilerInterceptor");
270
+ var _CommitProfiler = require("./highlight-updates/utils/CommitProfiler");
271
+ var _devtoolsComponentNames = require("./highlight-updates/utils/devtoolsComponentNames");
272
+ var _sharedUi = require("@buoy-gg/shared-ui");
252
273
  var _RenderTracker = require("./highlight-updates/utils/RenderTracker");
253
274
  var _RenderCauseDetector = require("./highlight-updates/utils/RenderCauseDetector");
254
275
  var _PerformanceLogger = require("./highlight-updates/utils/PerformanceLogger");
@@ -298,6 +319,52 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e
298
319
 
299
320
  // Profiler interceptor for debugging - captures what DevTools detects
300
321
 
322
+ // CommitProfiler — per-component render counts/durations over a capture window
323
+
324
+ // Bench "profile our own tools" mode: lets a dedicated benchmark screen
325
+ // include Buoy's tool UI in render capture (normally pruned wholesale).
326
+ // Highlight/events + perf-monitor components stay excluded regardless.
327
+
328
+ // Register the render-capture provider so perf-monitor benchmarks can capture
329
+ // renders without depending on this package. Runs at module scope: whenever
330
+ // this package is installed, FloatingDevTools require()s it (including on the
331
+ // fresh realm after a reload-between-cases batch step), and Metro evaluates
332
+ // module side effects regardless of the package.json "sideEffects" hint.
333
+
334
+ try {
335
+ (0, _sharedUi.setRenderCaptureProvider)({
336
+ isSupported: _CommitProfiler.CommitProfiler.isSupported,
337
+ start: _CommitProfiler.CommitProfiler.startCapture,
338
+ stop: _CommitProfiler.CommitProfiler.stopCapture,
339
+ // Real-time bounding boxes (the Render Highlighter visual) for the span
340
+ // of a recording. Saves the user's prior overlay state and hands back a
341
+ // restore fn: previously ON stays ON, silent tracking (events tool) gets
342
+ // re-suppressed, otherwise fully disabled again.
343
+ startLiveHighlights: () => {
344
+ try {
345
+ const wasEnabled = _HighlightUpdatesController.default.isEnabled();
346
+ const wasSuppressed = _HighlightUpdatesController.default.getHighlightsSuppressed();
347
+ _HighlightUpdatesController.default.enable(); // idempotent; clears suppression
348
+ return () => {
349
+ try {
350
+ if (!wasEnabled) {
351
+ _HighlightUpdatesController.default.disable(); // respects background tracking
352
+ } else if (wasSuppressed) {
353
+ _HighlightUpdatesController.default.setHighlightsSuppressed(true);
354
+ }
355
+ } catch {
356
+ // restoring the overlay must never throw into the recorder
357
+ }
358
+ };
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+ });
364
+ } catch {
365
+ // shared-ui too old to have the registry — capture is simply unavailable.
366
+ }
367
+
301
368
  // RenderTracker singleton for tracking render history
302
369
 
303
370
  // Convenience export for unified events integration
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from "react";
12
- import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
12
+ import { View, Text, StyleSheet } from "react-native";
13
13
  import { buoyColors } from "@buoy-gg/shared-ui";
14
14
  import HighlightUpdatesController from "./utils/HighlightUpdatesController";
15
15
  import { PerformanceLogger, markOverlayRendered } from "./utils/PerformanceLogger";
@@ -187,35 +187,31 @@ export function HighlightUpdatesOverlay({
187
187
  height: rect.height,
188
188
  borderColor: rect.color
189
189
  }],
190
- children: rect.count > 0 && (hasBadgePressHandler ? /*#__PURE__*/_jsx(TouchableOpacity, {
191
- onPress: () => handleBadgePress(rect.id),
190
+ children: rect.count > 0 && /*#__PURE__*/_jsx(View, {
192
191
  style: [styles.badge, {
193
192
  backgroundColor: rect.color
194
193
  }],
195
- activeOpacity: 0.7,
194
+ nativeID: `__highlight_badge_${rect.id}__`,
195
+ pointerEvents: hasBadgePressHandler ? "auto" : "none",
196
196
  hitSlop: {
197
197
  top: 8,
198
198
  bottom: 8,
199
199
  left: 8,
200
200
  right: 8
201
201
  },
202
+ onStartShouldSetResponder: () => !!hasBadgePressHandler
203
+ // Fire on touch-DOWN: on churning screens the badge views are
204
+ // torn down and recreated between finger-down and finger-up
205
+ // (every highlight event rebuilds the boxes), which kills the
206
+ // responder before a release can fire. Grant fires immediately.
207
+ ,
208
+ onResponderGrant: () => handleBadgePress(rect.id),
202
209
  children: /*#__PURE__*/_jsx(Text, {
203
210
  style: styles.badgeText,
204
211
  nativeID: `__highlight_text_${rect.id}__`,
205
212
  children: rect.count
206
213
  })
207
- }) : /*#__PURE__*/_jsx(View, {
208
- style: [styles.badge, {
209
- backgroundColor: rect.color
210
- }],
211
- nativeID: `__highlight_badge_${rect.id}__`,
212
- pointerEvents: "none",
213
- children: /*#__PURE__*/_jsx(Text, {
214
- style: styles.badgeText,
215
- nativeID: `__highlight_text_${rect.id}__`,
216
- children: rect.count
217
- })
218
- }))
214
+ })
219
215
  }, `highlight-${rect.id}`))]
220
216
  });
221
217
  }
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * CommitProfiler — per-component render counts + durations across a capture
5
+ * window, React-DevTools-profiler style, without React DevTools attached.
6
+ *
7
+ * Wraps `__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot` (the callback React
8
+ * invokes after every commit) and, while a capture is active, walks the
9
+ * committed fiber tree collecting composite fibers that performed work this
10
+ * commit. Self duration follows React's convention: `actualDuration` minus the
11
+ * sum of direct children's `actualDuration` (profiling timers are enabled in
12
+ * dev builds, where Buoy runs).
13
+ *
14
+ * This is intentionally separate from the traceUpdates flow the highlight
15
+ * overlay uses — traceUpdates emits host views with no timing data.
16
+ *
17
+ * Consumed by perf-monitor benchmarks via the shared-ui render-capture
18
+ * registry (see the registration in this package's index.tsx).
19
+ */
20
+
21
+ import { isDevToolsComponentName } from "./devtoolsComponentNames";
22
+
23
+ // React work tags for components that render user code.
24
+ const FunctionComponent = 0;
25
+ const ClassComponent = 1;
26
+ const ForwardRef = 11;
27
+ const MemoComponent = 14;
28
+ const SimpleMemoComponent = 15;
29
+
30
+ // React fiber flag: this fiber rendered (performed work) in the last commit.
31
+ // Stable value 0b1 across React 16-19 (effectTag pre-18, flags after).
32
+ const PerformedWork = 0b1;
33
+
34
+ /** Overhead guardrail: stop walking a commit past this many visited fibers. */
35
+ const MAX_FIBERS_PER_COMMIT = 10_000;
36
+
37
+ /** Bound on distinct component names tracked; overflow still counts in totals. */
38
+ const MAX_TRACKED_NAMES = 300;
39
+
40
+ /** Components returned in the summary. */
41
+ const TOP_COMPONENTS_CAP = 20;
42
+ let hookInstalled = false;
43
+ let active = false;
44
+ let sawDurations = false;
45
+ let totalCommits = 0;
46
+ let totalRenders = 0;
47
+ let totalRenderMs = 0;
48
+ let buckets = new Map();
49
+ function getHook() {
50
+ if (typeof global === "undefined") return null;
51
+ return global.__REACT_DEVTOOLS_GLOBAL_HOOK__ ?? null;
52
+ }
53
+ function getFlags(fiber) {
54
+ const f = fiber.flags ?? fiber.effectTag;
55
+ return typeof f === "number" ? f : 0;
56
+ }
57
+ function isCompositeTag(tag) {
58
+ return tag === FunctionComponent || tag === ClassComponent || tag === ForwardRef || tag === MemoComponent || tag === SimpleMemoComponent;
59
+ }
60
+
61
+ /** Resolve a composite fiber's display name (DevTools conventions). */
62
+ function getComponentName(fiber) {
63
+ const type = fiber.type;
64
+ try {
65
+ switch (fiber.tag) {
66
+ case FunctionComponent:
67
+ case ClassComponent:
68
+ return type?.displayName || type?.name || "Anonymous";
69
+ case ForwardRef:
70
+ return type?.displayName || type?.render?.displayName || type?.render?.name || "ForwardRef";
71
+ case MemoComponent:
72
+ case SimpleMemoComponent:
73
+ {
74
+ const inner = type?.type ?? fiber.elementType?.type;
75
+ return type?.displayName || inner?.displayName || inner?.name || "Memo";
76
+ }
77
+ default:
78
+ return "Anonymous";
79
+ }
80
+ } catch {
81
+ return "Anonymous";
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Self duration = own actualDuration minus direct children's. React's
87
+ * bubbleProperties adds every direct child's actualDuration into the parent,
88
+ * so subtracting them all back out is symmetric and exact.
89
+ */
90
+ function computeSelfMs(fiber) {
91
+ const actual = fiber.actualDuration;
92
+ if (typeof actual !== "number") return null;
93
+ let self = actual;
94
+ for (let child = fiber.child; child != null; child = child.sibling) {
95
+ if (typeof child.actualDuration === "number") self -= child.actualDuration;
96
+ }
97
+ return self > 0 ? self : 0;
98
+ }
99
+
100
+ // React Native framework wrapper components. Their renders are real work
101
+ // but the actionable signal lives on the OWNING component (a Touchable
102
+ // re-rendering is already visible as its parent re-rendering), so they're
103
+ // folded into the capture totals and kept OUT of the per-component list.
104
+ // This is a report-level filter only — unlike isDevToolsComponentName it
105
+ // must NOT prune subtrees (their children are user components).
106
+ const FRAMEWORK_WRAPPER_NAMES = new Set(["View", "Text", "Image", "ImageBackground", "ScrollView", "FlatList", "SectionList", "SafeAreaView", "KeyboardAvoidingView", "StatusBar", "ActivityIndicator", "RefreshControl", "Modal", "Switch", "Pressable", "PlatformPressable", "TouchableOpacity", "TouchableHighlight", "TouchableWithoutFeedback", "TouchableNativeFeedback", "PressabilityDebugView", "AnimatedComponent", "Wrapper"]);
107
+ const FRAMEWORK_WRAPPER_PREFIXES = ["Animated(",
108
+ // Animated(View), Animated(Text), Animated(FlatList), …
109
+ "Pressability", "Virtualized",
110
+ // VirtualizedList + its context providers
111
+ "CellRenderer"];
112
+ function isFrameworkWrapperName(name) {
113
+ if (FRAMEWORK_WRAPPER_NAMES.has(name)) return true;
114
+ for (let i = 0; i < FRAMEWORK_WRAPPER_PREFIXES.length; i++) {
115
+ if (name.startsWith(FRAMEWORK_WRAPPER_PREFIXES[i])) return true;
116
+ }
117
+ return false;
118
+ }
119
+ function recordRender(fiber) {
120
+ const name = getComponentName(fiber);
121
+ const selfMs = computeSelfMs(fiber);
122
+ if (selfMs !== null) sawDurations = true;
123
+ const ms = selfMs ?? 0;
124
+ totalRenders += 1;
125
+ totalRenderMs += ms;
126
+
127
+ // Framework wrappers count toward totals but never surface as a row.
128
+ if (isFrameworkWrapperName(name)) return;
129
+ let bucket = buckets.get(name);
130
+ if (!bucket) {
131
+ // Bounded map: overflow renders still count toward the totals above.
132
+ if (buckets.size >= MAX_TRACKED_NAMES) return;
133
+ bucket = {
134
+ renders: 0,
135
+ totalSelfMs: 0,
136
+ maxSelfMs: 0
137
+ };
138
+ buckets.set(name, bucket);
139
+ }
140
+ bucket.renders += 1;
141
+ bucket.totalSelfMs += ms;
142
+ if (ms > bucket.maxSelfMs) bucket.maxSelfMs = ms;
143
+ }
144
+
145
+ /**
146
+ * Iterative walk (child → sibling → return) of the committed tree. Never
147
+ * throws upward — a broken walk must not break React's commit.
148
+ */
149
+ function handleCommit(root) {
150
+ const start = root?.current;
151
+ if (!start) return;
152
+ const rendersBefore = totalRenders;
153
+ let visited = 0;
154
+ let fiber = start;
155
+ while (fiber != null) {
156
+ visited += 1;
157
+ if (visited > MAX_FIBERS_PER_COMMIT) break;
158
+ let skipSubtree = false;
159
+ const composite = isCompositeTag(fiber.tag);
160
+ if (composite) {
161
+ // Prune Buoy's own devtools UI wholesale.
162
+ if (isDevToolsComponentName(getComponentName(fiber))) {
163
+ skipSubtree = true;
164
+ } else if ((getFlags(fiber) & PerformedWork) !== 0) {
165
+ // PerformedWork is the only trustworthy "rendered this commit"
166
+ // signal — actualDuration is stale on bailed-out fibers.
167
+ recordRender(fiber);
168
+ }
169
+ }
170
+ if (!skipSubtree) {
171
+ // Prune subtrees with no work. subtreeFlags only exists on React 18+;
172
+ // when absent, always descend.
173
+ const subtreeFlags = fiber.subtreeFlags;
174
+ const noSubtreeWork = typeof subtreeFlags === "number" && (subtreeFlags & PerformedWork) === 0 && (getFlags(fiber) & PerformedWork) === 0;
175
+ if (!noSubtreeWork && fiber.child != null) {
176
+ fiber = fiber.child;
177
+ continue;
178
+ }
179
+ }
180
+
181
+ // No descend: advance to sibling, climbing up as needed.
182
+ while (fiber != null) {
183
+ if (fiber === start) return finalizeCommit(rendersBefore);
184
+ if (fiber.sibling != null) {
185
+ fiber = fiber.sibling;
186
+ break;
187
+ }
188
+ fiber = fiber.return;
189
+ }
190
+ }
191
+ finalizeCommit(rendersBefore);
192
+ }
193
+ function finalizeCommit(rendersBefore) {
194
+ if (totalRenders > rendersBefore) totalCommits += 1;
195
+ }
196
+
197
+ /**
198
+ * Install the onCommitFiberRoot wrapper once and never restore it — the
199
+ * `active` flag gates all work, and never restoring avoids clobber races if
200
+ * something else (e.g. React DevTools attaching mid-session) wraps the hook
201
+ * after us. The wrapper dies with the JS realm on reload.
202
+ */
203
+ function ensureHookInstalled() {
204
+ if (hookInstalled) return true;
205
+ const hook = getHook();
206
+ if (!hook) return false;
207
+ const original = hook.onCommitFiberRoot;
208
+ hook.onCommitFiberRoot = function (rendererID, root, ...rest) {
209
+ if (active) {
210
+ try {
211
+ handleCommit(root);
212
+ } catch {
213
+ // Capture must never break React's commit path.
214
+ }
215
+ }
216
+ return typeof original === "function" ? original.call(hook, rendererID, root, ...rest) : undefined;
217
+ };
218
+ hookInstalled = true;
219
+ return true;
220
+ }
221
+ function isSupported() {
222
+ if (typeof __DEV__ !== "undefined" && !__DEV__) return false;
223
+ const hook = getHook();
224
+ return Boolean(hook && hook.renderers && hook.renderers.size > 0);
225
+ }
226
+ function isCapturing() {
227
+ return active;
228
+ }
229
+
230
+ /** Begin a capture window, discarding any previous unclaimed data. */
231
+ function startCapture() {
232
+ if (!ensureHookInstalled()) return;
233
+ buckets = new Map();
234
+ sawDurations = false;
235
+ totalCommits = 0;
236
+ totalRenders = 0;
237
+ totalRenderMs = 0;
238
+ active = true;
239
+ }
240
+
241
+ /** End the window and return the aggregate (null if never started). */
242
+ function stopCapture() {
243
+ if (!active && totalRenders === 0 && buckets.size === 0) return null;
244
+ active = false;
245
+ const topComponents = Array.from(buckets.entries()).sort((a, b) => {
246
+ const byMs = b[1].totalSelfMs - a[1].totalSelfMs;
247
+ return byMs !== 0 ? byMs : b[1].renders - a[1].renders;
248
+ }).slice(0, TOP_COMPONENTS_CAP).map(([name, b]) => ({
249
+ name,
250
+ renders: b.renders,
251
+ totalMs: round2(b.totalSelfMs),
252
+ maxMs: round2(b.maxSelfMs),
253
+ avgMs: b.renders > 0 ? round2(b.totalSelfMs / b.renders) : 0
254
+ }));
255
+ const summary = {
256
+ totalCommits,
257
+ totalRenders,
258
+ capturedDurations: sawDurations,
259
+ totalRenderMs: round2(totalRenderMs),
260
+ topComponents
261
+ };
262
+ buckets = new Map();
263
+ totalCommits = 0;
264
+ totalRenders = 0;
265
+ totalRenderMs = 0;
266
+ return summary;
267
+ }
268
+ function round2(n) {
269
+ return Math.round(n * 100) / 100;
270
+ }
271
+ export const CommitProfiler = {
272
+ isSupported,
273
+ isCapturing,
274
+ startCapture,
275
+ stopCapture
276
+ };