@react-devtools-plus/scan 0.2.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/src/adapter.ts ADDED
@@ -0,0 +1,1257 @@
1
+ /**
2
+ * React Scan adapter for React DevTools integration
3
+ * Provides a clean interface to control React Scan from the DevTools UI
4
+ */
5
+
6
+ import type { AggregatedChanges, ChangeInfo, ComponentPerformanceData, ComponentTreeNode, FocusedComponentRenderInfo, PerformanceSummary, ReactDevtoolsScanOptions, ScanInstance } from './types'
7
+ import { _fiberRoots, getDisplayName, getFiberId, isCompositeFiber } from 'bippy'
8
+ import { getOptions as getScanOptions, ReactScanInternals, scan, setOptions as setScanOptions } from 'react-scan'
9
+
10
+ // Helper to get shared internals from global window
11
+ function getGlobalObject(key: string) {
12
+ if (typeof window === 'undefined')
13
+ return undefined
14
+
15
+ // Check parent window (Host App) first
16
+ try {
17
+ if (window.parent && window.parent !== window && (window.parent as any)[key]) {
18
+ return (window.parent as any)[key]
19
+ }
20
+ }
21
+ catch (e) {
22
+ // Accessing parent might fail cross-origin
23
+ }
24
+
25
+ // Fallback to current window
26
+ if ((window as any)[key]) {
27
+ return (window as any)[key]
28
+ }
29
+
30
+ return undefined
31
+ }
32
+
33
+ function getInternals() {
34
+ // react-scan exposes internals at window.__REACT_SCAN__.ReactScanInternals
35
+ try {
36
+ if (typeof window !== 'undefined') {
37
+ // Check parent window first (Host App)
38
+ if (window.parent && window.parent !== window && (window.parent as any).__REACT_SCAN__?.ReactScanInternals) {
39
+ return (window.parent as any).__REACT_SCAN__.ReactScanInternals
40
+ }
41
+ // Then check current window
42
+ if ((window as any).__REACT_SCAN__?.ReactScanInternals) {
43
+ return (window as any).__REACT_SCAN__.ReactScanInternals
44
+ }
45
+ }
46
+ }
47
+ catch {
48
+ // Accessing parent might fail cross-origin
49
+ }
50
+ return ReactScanInternals
51
+ }
52
+
53
+ function getSetOptions() {
54
+ // react-scan exposes setOptions via the module export
55
+ // We need to use the one from the same react-scan instance
56
+ try {
57
+ if (typeof window !== 'undefined') {
58
+ // Check parent window first (Host App)
59
+ if (window.parent && window.parent !== window && (window.parent as any).__REACT_SCAN__?.setOptions) {
60
+ return (window.parent as any).__REACT_SCAN__.setOptions
61
+ }
62
+ // Then check current window
63
+ if ((window as any).__REACT_SCAN__?.setOptions) {
64
+ return (window as any).__REACT_SCAN__.setOptions
65
+ }
66
+ }
67
+ }
68
+ catch {
69
+ // Accessing parent might fail cross-origin
70
+ }
71
+ return setScanOptions
72
+ }
73
+
74
+ function getGetOptions() {
75
+ return getGlobalObject('__REACT_SCAN_GET_OPTIONS__') || getScanOptions
76
+ }
77
+
78
+ function getScan() {
79
+ return getGlobalObject('__REACT_SCAN_SCAN__') || scan
80
+ }
81
+
82
+ /**
83
+ * Update toolbar visibility in the shadow DOM
84
+ */
85
+ function updateToolbarVisibility(visible: boolean) {
86
+ if (typeof document === 'undefined')
87
+ return
88
+
89
+ try {
90
+ const update = () => {
91
+ const root = document.getElementById('react-scan-root')
92
+ if (!root || !root.shadowRoot)
93
+ return
94
+
95
+ let style = root.shadowRoot.getElementById('react-scan-devtools-style')
96
+ if (!style) {
97
+ style = document.createElement('style')
98
+ style.id = 'react-scan-devtools-style'
99
+ root.shadowRoot.appendChild(style)
100
+ }
101
+
102
+ style.textContent = visible ? '' : '#react-scan-toolbar { display: none !important; }'
103
+ }
104
+
105
+ // Try immediately
106
+ update()
107
+
108
+ // And retry in next frame to ensure DOM is ready if just initialized
109
+ requestAnimationFrame(update)
110
+ // And one more time for good measure given React Scan's async nature
111
+ setTimeout(update, 100)
112
+ }
113
+ catch (e) {
114
+ // Ignore errors
115
+ }
116
+ }
117
+
118
+ let scanInstance: ScanInstance | null = null
119
+ let currentOptions: ReactDevtoolsScanOptions = {}
120
+
121
+ // Store for focused component render tracking
122
+ interface FocusedComponentTracker {
123
+ componentName: string
124
+ renderCount: number
125
+ changes: AggregatedChanges
126
+ timestamp: number
127
+ unsubscribe: (() => void) | null
128
+ }
129
+
130
+ let focusedComponentTracker: FocusedComponentTracker | null = null
131
+ const focusedComponentChangeCallbacks = new Set<(info: FocusedComponentRenderInfo) => void>()
132
+
133
+ /**
134
+ * Convert react-scan's internal changes format to our serializable format
135
+ */
136
+ function convertChangesToSerializable(changesMap: Map<string, any>): ChangeInfo[] {
137
+ const result: ChangeInfo[] = []
138
+ changesMap.forEach((value, key) => {
139
+ result.push({
140
+ name: value.changes?.name || key,
141
+ previousValue: serializeValue(value.changes?.previousValue),
142
+ currentValue: serializeValue(value.changes?.currentValue),
143
+ count: value.changes?.count || 1,
144
+ })
145
+ })
146
+ return result
147
+ }
148
+
149
+ /**
150
+ * Serialize a value for RPC transfer
151
+ */
152
+ function serializeValue(value: any): any {
153
+ if (value === undefined)
154
+ return undefined
155
+ if (value === null)
156
+ return null
157
+ if (typeof value === 'function')
158
+ return `[Function: ${value.name || 'anonymous'}]`
159
+ if (typeof value === 'symbol')
160
+ return `[Symbol: ${value.description || ''}]`
161
+ if (value instanceof Element)
162
+ return `[Element: ${value.tagName}]`
163
+ if (typeof value === 'object') {
164
+ if (Array.isArray(value)) {
165
+ if (value.length > 10)
166
+ return `[Array(${value.length})]`
167
+ return value.map(serializeValue)
168
+ }
169
+ // Handle React elements
170
+ if (value.$$typeof)
171
+ return `[React Element]`
172
+ // Handle circular references and complex objects
173
+ try {
174
+ const keys = Object.keys(value)
175
+ if (keys.length > 20)
176
+ return `[Object with ${keys.length} keys]`
177
+ const serialized: Record<string, any> = {}
178
+ for (const key of keys.slice(0, 20)) {
179
+ serialized[key] = serializeValue(value[key])
180
+ }
181
+ return serialized
182
+ }
183
+ catch {
184
+ return '[Object]'
185
+ }
186
+ }
187
+ return value
188
+ }
189
+
190
+ /**
191
+ * Get the current focused fiber and its ID
192
+ */
193
+ function getFocusedFiberInfo(): { fiber: any, fiberId: string } | null {
194
+ try {
195
+ const { Store } = getInternals()
196
+ if (Store?.inspectState?.value?.kind === 'focused') {
197
+ const fiber = Store.inspectState.value.fiber
198
+ if (fiber) {
199
+ // Use bippy's getFiberId to get the correct fiber ID that matches react-scan's internal usage
200
+ const fiberId = getFiberId(fiber)
201
+ return { fiber, fiberId }
202
+ }
203
+ }
204
+ return null
205
+ }
206
+ catch {
207
+ return null
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Subscribe to changes for a specific fiber ID
213
+ */
214
+ function subscribeToFiberChanges(fiberId: string, callback: (changes: any) => void): () => void {
215
+ try {
216
+ const { Store } = getInternals()
217
+ if (!Store?.changesListeners)
218
+ return () => {}
219
+
220
+ let listeners = Store.changesListeners.get(fiberId)
221
+ if (!listeners) {
222
+ listeners = []
223
+ Store.changesListeners.set(fiberId, listeners)
224
+ }
225
+
226
+ listeners.push(callback)
227
+
228
+ return () => {
229
+ const currentListeners = Store.changesListeners.get(fiberId)
230
+ if (currentListeners) {
231
+ const index = currentListeners.indexOf(callback)
232
+ if (index > -1) {
233
+ currentListeners.splice(index, 1)
234
+ }
235
+ }
236
+ }
237
+ }
238
+ catch {
239
+ return () => {}
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Setup onRender callback to track focused component renders
245
+ * This is more reliable than changesListeners as it doesn't depend on showToolbar
246
+ */
247
+ function setupOnRenderCallback(): () => void {
248
+ try {
249
+ const internals = getInternals()
250
+ if (!internals) {
251
+ return () => {}
252
+ }
253
+
254
+ // Get the current onRender callback if any
255
+ const originalOnRender = internals.options?.value?.onRender
256
+
257
+ // Create our render tracking callback
258
+ const trackingOnRender = (fiber: any, renders: any[]) => {
259
+ // Call original callback first
260
+ if (originalOnRender) {
261
+ originalOnRender(fiber, renders)
262
+ }
263
+
264
+ // Always track fiber render count for component tree
265
+ trackFiberRender(fiber)
266
+
267
+ // Check if we have a focused component tracker
268
+ if (!focusedComponentTracker) {
269
+ return
270
+ }
271
+
272
+ // Get the fiber name to compare with focused component
273
+ const fiberName = getDisplayName(fiber.type) || 'Unknown'
274
+
275
+ // Compare by component name (simpler approach)
276
+ if (fiberName !== focusedComponentTracker.componentName) {
277
+ return
278
+ }
279
+
280
+ // Update tracker
281
+ focusedComponentTracker.renderCount++
282
+ focusedComponentTracker.timestamp = Date.now()
283
+
284
+ // Extract changes directly from fiber since react-scan's trackChanges is false by default
285
+ const propsChanges: ChangeInfo[] = []
286
+ const stateChanges: ChangeInfo[] = []
287
+ const contextChanges: ChangeInfo[] = []
288
+
289
+ try {
290
+ // Get props changes
291
+ const currentProps = fiber.memoizedProps || {}
292
+ const prevProps = fiber.alternate?.memoizedProps || {}
293
+
294
+ for (const key of Object.keys(currentProps)) {
295
+ if (key === 'children')
296
+ continue
297
+ const prevValue = prevProps[key]
298
+ const currentValue = currentProps[key]
299
+ if (prevValue !== currentValue) {
300
+ propsChanges.push({
301
+ name: key,
302
+ previousValue: serializeValue(prevValue),
303
+ currentValue: serializeValue(currentValue),
304
+ count: 1,
305
+ })
306
+ }
307
+ }
308
+
309
+ // Get state changes (for functional components with hooks)
310
+ let currentState = fiber.memoizedState
311
+ let prevState = fiber.alternate?.memoizedState
312
+ let hookIndex = 0
313
+
314
+ while (currentState) {
315
+ // Check if this is a useState/useReducer hook (has memoizedState)
316
+ if (currentState.memoizedState !== undefined) {
317
+ const currentValue = currentState.memoizedState
318
+ const prevValue = prevState?.memoizedState
319
+
320
+ if (prevValue !== currentValue && prevState) {
321
+ stateChanges.push({
322
+ name: `Hook ${hookIndex + 1}`,
323
+ previousValue: serializeValue(prevValue),
324
+ currentValue: serializeValue(currentValue),
325
+ count: 1,
326
+ })
327
+ }
328
+ }
329
+
330
+ currentState = currentState.next
331
+ prevState = prevState?.next
332
+ hookIndex++
333
+ }
334
+ }
335
+ catch {
336
+ // Ignore extraction errors
337
+ }
338
+
339
+ // Accumulate changes - increment count if same name exists, otherwise add new
340
+ for (const change of propsChanges) {
341
+ const existing = focusedComponentTracker.changes.propsChanges.find(c => c.name === change.name)
342
+ if (existing) {
343
+ existing.count++
344
+ existing.previousValue = change.previousValue
345
+ existing.currentValue = change.currentValue
346
+ }
347
+ else {
348
+ focusedComponentTracker.changes.propsChanges.push(change)
349
+ }
350
+ }
351
+
352
+ for (const change of stateChanges) {
353
+ const existing = focusedComponentTracker.changes.stateChanges.find(c => c.name === change.name)
354
+ if (existing) {
355
+ existing.count++
356
+ existing.previousValue = change.previousValue
357
+ existing.currentValue = change.currentValue
358
+ }
359
+ else {
360
+ focusedComponentTracker.changes.stateChanges.push(change)
361
+ }
362
+ }
363
+
364
+ for (const change of contextChanges) {
365
+ const existing = focusedComponentTracker.changes.contextChanges.find(c => c.name === change.name)
366
+ if (existing) {
367
+ existing.count++
368
+ existing.previousValue = change.previousValue
369
+ existing.currentValue = change.currentValue
370
+ }
371
+ else {
372
+ focusedComponentTracker.changes.contextChanges.push(change)
373
+ }
374
+ }
375
+
376
+ // Notify all callbacks
377
+ const info: FocusedComponentRenderInfo = {
378
+ componentName: focusedComponentTracker.componentName,
379
+ renderCount: focusedComponentTracker.renderCount,
380
+ changes: focusedComponentTracker.changes,
381
+ timestamp: focusedComponentTracker.timestamp,
382
+ }
383
+
384
+ focusedComponentChangeCallbacks.forEach((cb) => {
385
+ try {
386
+ cb(info)
387
+ }
388
+ catch {
389
+ // Ignore callback errors
390
+ }
391
+ })
392
+ }
393
+
394
+ // Set onRender directly on window.__REACT_SCAN__.ReactScanInternals.options.value
395
+ try {
396
+ const globalReactScan = (window as any).__REACT_SCAN__
397
+ if (globalReactScan?.ReactScanInternals?.options?.value) {
398
+ const currentOpts = globalReactScan.ReactScanInternals.options.value
399
+ globalReactScan.ReactScanInternals.options.value = {
400
+ ...currentOpts,
401
+ onRender: trackingOnRender,
402
+ }
403
+ }
404
+ }
405
+ catch {
406
+ // Ignore errors setting onRender
407
+ }
408
+
409
+ // Ensure instrumentation is not paused if we want to track renders
410
+ if (internals.instrumentation?.isPaused) {
411
+ internals.instrumentation.isPaused.value = false
412
+ }
413
+
414
+ return () => {
415
+ // Restore original onRender callback
416
+ try {
417
+ const globalReactScan = (window as any).__REACT_SCAN__
418
+ if (globalReactScan?.ReactScanInternals?.options?.value) {
419
+ globalReactScan.ReactScanInternals.options.value.onRender = originalOnRender || null
420
+ }
421
+ }
422
+ catch (e) {
423
+ // ignore
424
+ }
425
+ }
426
+ }
427
+ catch {
428
+ return () => {}
429
+ }
430
+ }
431
+
432
+ // Track whether onRender callback is set up
433
+ let onRenderCleanup: (() => void) | null = null
434
+
435
+ // Internal FPS counter
436
+ let fps = 60
437
+ let frameCount = 0
438
+ let lastTime = typeof performance !== 'undefined' ? performance.now() : Date.now()
439
+
440
+ const updateFPS = () => {
441
+ if (typeof performance === 'undefined' || typeof requestAnimationFrame === 'undefined')
442
+ return
443
+
444
+ frameCount++
445
+ const now = performance.now()
446
+ if (now - lastTime >= 1000) {
447
+ fps = frameCount
448
+ frameCount = 0
449
+ lastTime = now
450
+ }
451
+ requestAnimationFrame(updateFPS)
452
+ }
453
+
454
+ // Start FPS tracking
455
+ if (typeof requestAnimationFrame !== 'undefined') {
456
+ requestAnimationFrame(updateFPS)
457
+ }
458
+
459
+ // Component render count tracking
460
+ const componentRenderCounts = new Map<number, number>()
461
+
462
+ /**
463
+ * Find React fiber from a DOM element
464
+ */
465
+ function getFiberFromElement(element: Element): any {
466
+ // React 16+ uses __reactFiber$ prefix
467
+ // React 17+ might use __reactInternalInstance$
468
+ const keys = Object.keys(element)
469
+ for (const key of keys) {
470
+ if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {
471
+ return (element as any)[key]
472
+ }
473
+ }
474
+ return null
475
+ }
476
+
477
+ /**
478
+ * Find the root fiber by traversing up the tree
479
+ */
480
+ function findRootFiber(fiber: any): any {
481
+ if (!fiber)
482
+ return null
483
+
484
+ let current = fiber
485
+ while (current.return) {
486
+ current = current.return
487
+ }
488
+
489
+ // current is now the HostRoot fiber
490
+ // The FiberRoot is stored in current.stateNode
491
+ return current.stateNode || current
492
+ }
493
+
494
+ /**
495
+ * Get fiber roots by finding React root containers in the DOM
496
+ */
497
+ function getFiberRoots(): Set<any> {
498
+ const roots = new Set<any>()
499
+
500
+ const findRootsInDocument = (doc: Document) => {
501
+ try {
502
+ // Common React root element IDs
503
+ const rootSelectors = ['#root', '#app', '#__next', '[data-reactroot]', '#react-root']
504
+
505
+ for (const selector of rootSelectors) {
506
+ const elements = doc.querySelectorAll(selector)
507
+ elements.forEach((element) => {
508
+ // Try the element itself first
509
+ let fiber = getFiberFromElement(element)
510
+
511
+ // If not found, try the first child element (React usually attaches fiber to rendered elements)
512
+ if (!fiber && element.firstElementChild) {
513
+ fiber = getFiberFromElement(element.firstElementChild)
514
+ }
515
+
516
+ // Try any descendant with fiber
517
+ if (!fiber) {
518
+ const descendants = element.querySelectorAll('*')
519
+ for (const desc of descendants) {
520
+ fiber = getFiberFromElement(desc)
521
+ if (fiber)
522
+ break
523
+ }
524
+ }
525
+
526
+ if (fiber) {
527
+ const rootFiber = findRootFiber(fiber)
528
+ if (rootFiber) {
529
+ roots.add(rootFiber)
530
+ }
531
+ }
532
+ })
533
+ }
534
+
535
+ // Also try to find any element with React fiber
536
+ if (roots.size === 0) {
537
+ const allElements = doc.querySelectorAll('*')
538
+ for (const element of allElements) {
539
+ const fiber = getFiberFromElement(element)
540
+ if (fiber) {
541
+ const rootFiber = findRootFiber(fiber)
542
+ if (rootFiber) {
543
+ roots.add(rootFiber)
544
+ break // Found one root, that's enough
545
+ }
546
+ }
547
+ }
548
+ }
549
+ }
550
+ catch {
551
+ // Ignore errors
552
+ }
553
+ }
554
+
555
+ try {
556
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
557
+ // Try parent window first (Host App where React runs)
558
+ if (window.parent && window.parent !== window) {
559
+ try {
560
+ findRootsInDocument(window.parent.document)
561
+ }
562
+ catch {
563
+ // Cross-origin access denied
564
+ }
565
+ }
566
+
567
+ // Also try current window
568
+ if (roots.size === 0) {
569
+ findRootsInDocument(document)
570
+ }
571
+ }
572
+
573
+ // Fallback: Try bippy's _fiberRoots
574
+ if (roots.size === 0 && _fiberRoots) {
575
+ // WeakSet cannot be iterated, but we can try to use it if it's actually a Set
576
+ if (typeof (_fiberRoots as any).forEach === 'function') {
577
+ (_fiberRoots as any).forEach((root: any) => roots.add(root))
578
+ }
579
+ }
580
+ }
581
+ catch {
582
+ // Ignore errors
583
+ }
584
+
585
+ return roots
586
+ }
587
+
588
+ /**
589
+ * Get render count for a fiber from react-scan's reportData
590
+ */
591
+ function getFiberRenderCount(fiber: any): number {
592
+ try {
593
+ const fiberId = getFiberId(fiber)
594
+ // Check our local tracking first
595
+ const localCount = componentRenderCounts.get(fiberId)
596
+ if (localCount !== undefined) {
597
+ return localCount
598
+ }
599
+
600
+ // Try to get from react-scan's Store.reportData
601
+ const { Store } = getInternals()
602
+ if (Store?.reportData) {
603
+ const componentName = getDisplayName(fiber.type) || 'Unknown'
604
+ let totalCount = 0
605
+ Store.reportData.forEach((data: any) => {
606
+ if (data.componentName === componentName) {
607
+ totalCount += data.count || 0
608
+ }
609
+ })
610
+ return totalCount
611
+ }
612
+ }
613
+ catch {
614
+ // Ignore errors
615
+ }
616
+ return 0
617
+ }
618
+
619
+ /**
620
+ * Collect all composite component descendants from a fiber subtree
621
+ */
622
+ function collectCompositeDescendants(fiber: any, depth: number, maxDepth: number, results: ComponentTreeNode[]): void {
623
+ if (!fiber || depth > maxDepth)
624
+ return
625
+
626
+ try {
627
+ const isComposite = isCompositeFiber(fiber)
628
+ const name = getDisplayName(fiber.type)
629
+
630
+ if (isComposite && name) {
631
+ // Found a composite component, add it to results
632
+ const node = buildTreeNode(fiber, depth, maxDepth)
633
+ if (node) {
634
+ results.push(node)
635
+ }
636
+ }
637
+ else {
638
+ // Not composite, continue searching in children
639
+ let child = fiber.child
640
+ while (child) {
641
+ collectCompositeDescendants(child, depth, maxDepth, results)
642
+ child = child.sibling
643
+ }
644
+ }
645
+ }
646
+ catch {
647
+ // Ignore errors
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Build component tree node from fiber
653
+ */
654
+ function buildTreeNode(fiber: any, depth: number = 0, maxDepth: number = 50): ComponentTreeNode | null {
655
+ if (!fiber || depth > maxDepth)
656
+ return null
657
+
658
+ try {
659
+ // Only include composite components (not DOM elements)
660
+ const isComposite = isCompositeFiber(fiber)
661
+ const name = getDisplayName(fiber.type)
662
+
663
+ // For composite components, create a node
664
+ if (isComposite && name) {
665
+ const fiberId = getFiberId(fiber)
666
+ const renderCount = getFiberRenderCount(fiber)
667
+
668
+ const node: ComponentTreeNode = {
669
+ id: String(fiberId),
670
+ name,
671
+ type: typeof fiber.type === 'function' ? 'function' : 'class',
672
+ renderCount,
673
+ lastRenderTime: 0,
674
+ children: [],
675
+ }
676
+
677
+ // Process children - collect all composite descendants
678
+ let child = fiber.child
679
+ while (child) {
680
+ collectCompositeDescendants(child, depth + 1, maxDepth, node.children)
681
+ child = child.sibling
682
+ }
683
+
684
+ return node
685
+ }
686
+
687
+ // For non-composite fibers, traverse children to find composite descendants
688
+ let child = fiber.child
689
+ const compositeChildren: ComponentTreeNode[] = []
690
+ while (child) {
691
+ collectCompositeDescendants(child, depth, maxDepth, compositeChildren)
692
+ child = child.sibling
693
+ }
694
+
695
+ // If we found composite children but this fiber isn't composite,
696
+ // return the first child as a proxy (or null if multiple)
697
+ if (compositeChildren.length === 1) {
698
+ return compositeChildren[0]
699
+ }
700
+
701
+ return null
702
+ }
703
+ catch {
704
+ return null
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Extract component tree from fiber roots
710
+ */
711
+ function extractComponentTree(): ComponentTreeNode[] {
712
+ const trees: ComponentTreeNode[] = []
713
+
714
+ try {
715
+ const fiberRoots = getFiberRoots()
716
+
717
+ fiberRoots.forEach((root: any) => {
718
+ // FiberRoot has current property pointing to the HostRoot fiber
719
+ const rootFiber = root.current || root
720
+ if (!rootFiber)
721
+ return
722
+
723
+ // The actual component tree starts from the HostRoot's child
724
+ let child = rootFiber.child
725
+ while (child) {
726
+ const node = buildTreeNode(child, 0)
727
+ if (node) {
728
+ trees.push(node)
729
+ }
730
+ child = child.sibling
731
+ }
732
+ })
733
+ }
734
+ catch {
735
+ // Ignore errors
736
+ }
737
+
738
+ return trees
739
+ }
740
+
741
+ /**
742
+ * Track render count for a fiber (called from onRender callback)
743
+ */
744
+ function trackFiberRender(fiber: any): void {
745
+ try {
746
+ const fiberId = getFiberId(fiber)
747
+ const current = componentRenderCounts.get(fiberId) || 0
748
+ componentRenderCounts.set(fiberId, current + 1)
749
+ }
750
+ catch {
751
+ // Ignore errors
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Extract performance data from React Scan internals
757
+ */
758
+ function extractPerformanceData(): ComponentPerformanceData[] {
759
+ const performanceData: ComponentPerformanceData[] = []
760
+
761
+ try {
762
+ const { Store } = getInternals()
763
+
764
+ if (!Store || !Store.reportData) {
765
+ return performanceData
766
+ }
767
+
768
+ const componentStats = new Map<string, {
769
+ renderCount: number
770
+ totalTime: number
771
+ unnecessaryRenders: number
772
+ lastRenderTime: number | null
773
+ }>()
774
+
775
+ Store.reportData.forEach((renderData) => {
776
+ const componentName = renderData.componentName || 'Unknown'
777
+ const existing = componentStats.get(componentName) || {
778
+ renderCount: 0,
779
+ totalTime: 0,
780
+ unnecessaryRenders: 0,
781
+ lastRenderTime: null,
782
+ }
783
+
784
+ existing.renderCount += renderData.count || 0
785
+ existing.totalTime += renderData.time || 0
786
+
787
+ if (renderData.unnecessary) {
788
+ existing.unnecessaryRenders++
789
+ }
790
+
791
+ if (renderData.time !== null && renderData.time !== undefined) {
792
+ existing.lastRenderTime = renderData.time
793
+ }
794
+
795
+ componentStats.set(componentName, existing)
796
+ })
797
+
798
+ componentStats.forEach((stats, componentName) => {
799
+ performanceData.push({
800
+ componentName,
801
+ renderCount: stats.renderCount,
802
+ totalTime: stats.totalTime,
803
+ averageTime: stats.renderCount > 0 ? stats.totalTime / stats.renderCount : 0,
804
+ unnecessaryRenders: stats.unnecessaryRenders,
805
+ lastRenderTime: stats.lastRenderTime,
806
+ })
807
+ })
808
+
809
+ performanceData.sort((a, b) => b.totalTime - a.totalTime)
810
+ }
811
+ catch {
812
+ // Ignore extraction errors
813
+ }
814
+
815
+ return performanceData
816
+ }
817
+
818
+ /**
819
+ * Calculate performance summary
820
+ */
821
+ function calculatePerformanceSummary(data: ComponentPerformanceData[]): PerformanceSummary {
822
+ const totalRenders = data.reduce((sum, item) => sum + item.renderCount, 0)
823
+ const totalComponents = data.length
824
+ const unnecessaryRenders = data.reduce((sum, item) => sum + item.unnecessaryRenders, 0)
825
+ const totalTime = data.reduce((sum, item) => sum + item.totalTime, 0)
826
+ const averageRenderTime = totalRenders > 0 ? totalTime / totalRenders : 0
827
+ const slowestComponents = data.slice(0, 10)
828
+
829
+ return {
830
+ totalRenders,
831
+ totalComponents,
832
+ unnecessaryRenders,
833
+ averageRenderTime,
834
+ slowestComponents,
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Create a scan instance with DevTools integration
840
+ */
841
+ function createScanInstance(options: ReactDevtoolsScanOptions): ScanInstance {
842
+ currentOptions = options
843
+
844
+ return {
845
+ getOptions: () => currentOptions,
846
+
847
+ setOptions: (newOptions: Partial<ReactDevtoolsScanOptions>) => {
848
+ currentOptions = { ...currentOptions, ...newOptions }
849
+
850
+ // We need to force showToolbar to true in the actual options passed to react-scan
851
+ // so that the container/inspector is initialized. We'll handle visibility via CSS.
852
+ const effectiveOptions = { ...currentOptions }
853
+ if (effectiveOptions.enabled) {
854
+ effectiveOptions.showToolbar = true
855
+ }
856
+
857
+ if (currentOptions.enabled) {
858
+ const scanFn = getScan()
859
+ if (scanFn) {
860
+ scanFn(effectiveOptions)
861
+ }
862
+ else {
863
+ getSetOptions()(effectiveOptions)
864
+ }
865
+
866
+ // Apply visibility override
867
+ updateToolbarVisibility(!!currentOptions.showToolbar)
868
+ }
869
+ else {
870
+ getSetOptions()(effectiveOptions)
871
+ }
872
+ },
873
+
874
+ start: () => {
875
+ const internals = getInternals()
876
+ const { instrumentation } = internals || {}
877
+
878
+ if (instrumentation && instrumentation.isPaused) {
879
+ instrumentation.isPaused.value = false
880
+ }
881
+
882
+ const options = { ...currentOptions, enabled: true }
883
+ // Force showToolbar to true
884
+ const effectiveOptions = { ...options, showToolbar: true }
885
+
886
+ const scanFn = getScan()
887
+ const isInstrumented = internals?.instrumentation && !internals.instrumentation.isPaused.value
888
+
889
+ // Only reinitialize if not already instrumented
890
+ if (scanFn) {
891
+ // Always call scanFn to ensure options are applied and it's active
892
+ scanFn(effectiveOptions)
893
+ }
894
+ else {
895
+ // Fallback to setOptions if scanFn not available
896
+ const current = getGetOptions()()?.value || {}
897
+ const hasChanges = Object.keys(effectiveOptions).some((key) => {
898
+ return effectiveOptions[key as keyof ReactDevtoolsScanOptions] !== current[key as keyof typeof current]
899
+ })
900
+
901
+ if (hasChanges || !isInstrumented) {
902
+ getSetOptions()(effectiveOptions)
903
+ }
904
+ }
905
+
906
+ currentOptions = options
907
+ // Apply visibility override
908
+ updateToolbarVisibility(!!currentOptions.showToolbar)
909
+
910
+ // Re-apply onRender callback AFTER scan() has reset options
911
+ // This is critical because scan() calls setOptions() which replaces the entire options.value object
912
+ if (onRenderCleanup) {
913
+ onRenderCleanup()
914
+ }
915
+ onRenderCleanup = setupOnRenderCallback()
916
+ },
917
+
918
+ stop: () => {
919
+ const options = { ...currentOptions, enabled: false }
920
+ currentOptions = options
921
+ getSetOptions()(options)
922
+ },
923
+
924
+ isActive: () => {
925
+ const opts = getGetOptions()()
926
+ if (opts && typeof opts === 'object' && 'value' in opts) {
927
+ return opts.value.enabled === true
928
+ }
929
+ return opts?.enabled === true
930
+ },
931
+
932
+ hideToolbar: () => {
933
+ currentOptions.showToolbar = false
934
+ updateToolbarVisibility(false)
935
+ },
936
+
937
+ showToolbar: () => {
938
+ currentOptions.showToolbar = true
939
+ updateToolbarVisibility(true)
940
+ },
941
+
942
+ getToolbarVisibility: () => {
943
+ const opts = getGetOptions()()
944
+ if (opts && typeof opts === 'object' && 'value' in opts) {
945
+ return opts.value.showToolbar !== false
946
+ }
947
+ return opts?.showToolbar !== false
948
+ },
949
+
950
+ getPerformanceData: () => {
951
+ return extractPerformanceData()
952
+ },
953
+
954
+ getPerformanceSummary: () => {
955
+ const data = extractPerformanceData()
956
+ return calculatePerformanceSummary(data)
957
+ },
958
+
959
+ clearPerformanceData: () => {
960
+ try {
961
+ const { Store } = getInternals()
962
+ if (Store?.reportData) {
963
+ Store.reportData.clear()
964
+ }
965
+ if (Store?.legacyReportData) {
966
+ Store.legacyReportData.clear()
967
+ }
968
+ }
969
+ catch {
970
+ // Ignore errors
971
+ }
972
+ },
973
+
974
+ startInspecting: () => {
975
+ try {
976
+ const { Store } = getInternals()
977
+ if (Store?.inspectState) {
978
+ Store.inspectState.value = {
979
+ kind: 'inspecting',
980
+ hoveredDomElement: null,
981
+ }
982
+ }
983
+ }
984
+ catch {
985
+ // Ignore errors
986
+ }
987
+ },
988
+
989
+ stopInspecting: () => {
990
+ try {
991
+ const { Store } = getInternals()
992
+ if (Store?.inspectState) {
993
+ Store.inspectState.value = {
994
+ kind: 'inspect-off',
995
+ }
996
+ }
997
+ }
998
+ catch {
999
+ // Ignore errors
1000
+ }
1001
+ },
1002
+
1003
+ isInspecting: () => {
1004
+ try {
1005
+ const { Store } = getInternals()
1006
+ if (Store?.inspectState) {
1007
+ return Store.inspectState.value.kind === 'inspecting'
1008
+ }
1009
+ return false
1010
+ }
1011
+ catch {
1012
+ return false
1013
+ }
1014
+ },
1015
+
1016
+ focusComponent: (fiber: any) => {
1017
+ try {
1018
+ const { Store } = getInternals()
1019
+ if (!fiber || !Store?.inspectState)
1020
+ return
1021
+
1022
+ let domElement: Element | null = null
1023
+ if (fiber.stateNode && fiber.stateNode instanceof Element) {
1024
+ domElement = fiber.stateNode
1025
+ }
1026
+
1027
+ if (domElement) {
1028
+ Store.inspectState.value = {
1029
+ kind: 'focused',
1030
+ focusedDomElement: domElement,
1031
+ fiber,
1032
+ }
1033
+ }
1034
+ }
1035
+ catch {
1036
+ // Ignore errors
1037
+ }
1038
+ },
1039
+
1040
+ getFocusedComponent: () => {
1041
+ try {
1042
+ const { Store } = getInternals()
1043
+ if (Store?.inspectState) {
1044
+ const state = Store.inspectState.value
1045
+ if (state.kind === 'focused') {
1046
+ const fiberId = getFiberId(state.fiber)
1047
+ return {
1048
+ componentName: getDisplayName(state.fiber.type) || 'Unknown',
1049
+ componentId: String(fiberId),
1050
+ fiber: state.fiber,
1051
+ domElement: state.focusedDomElement,
1052
+ }
1053
+ }
1054
+ }
1055
+ return null
1056
+ }
1057
+ catch {
1058
+ return null
1059
+ }
1060
+ },
1061
+
1062
+ onInspectStateChange: (callback: (state: any) => void) => {
1063
+ try {
1064
+ const { Store } = getInternals()
1065
+ if (Store?.inspectState) {
1066
+ // Set up the onRender callback for tracking renders
1067
+ if (!onRenderCleanup) {
1068
+ onRenderCleanup = setupOnRenderCallback()
1069
+ }
1070
+
1071
+ // Subscribe to inspect state changes
1072
+ const unsubscribe = Store.inspectState.subscribe((state: any) => {
1073
+ // Update focused component tracker when state changes
1074
+ if (state.kind === 'focused') {
1075
+ const componentName = getDisplayName(state.fiber?.type) || 'Unknown'
1076
+ const fiberInfo = getFocusedFiberInfo()
1077
+
1078
+ // Initialize or update tracker
1079
+ if (!focusedComponentTracker || focusedComponentTracker.componentName !== componentName) {
1080
+ // Clean up previous subscription
1081
+ if (focusedComponentTracker?.unsubscribe) {
1082
+ focusedComponentTracker.unsubscribe()
1083
+ }
1084
+
1085
+ focusedComponentTracker = {
1086
+ componentName,
1087
+ renderCount: 0,
1088
+ changes: { propsChanges: [], stateChanges: [], contextChanges: [] },
1089
+ timestamp: Date.now(),
1090
+ unsubscribe: null,
1091
+ }
1092
+
1093
+ // Subscribe to changes for this fiber using the correct fiberId from bippy
1094
+ // This is a backup mechanism - main tracking is done via onRender callback
1095
+ if (fiberInfo) {
1096
+ focusedComponentTracker.unsubscribe = subscribeToFiberChanges(fiberInfo.fiberId, (changes: any) => {
1097
+ if (focusedComponentTracker) {
1098
+ focusedComponentTracker.renderCount++
1099
+ focusedComponentTracker.timestamp = Date.now()
1100
+
1101
+ // Convert changes to serializable format
1102
+ // changes is { propsChanges: [...], stateChanges: [...], contextChanges: [...] }
1103
+ if (changes.propsChanges && Array.isArray(changes.propsChanges)) {
1104
+ focusedComponentTracker.changes.propsChanges = changes.propsChanges.map((c: any) => ({
1105
+ name: c.name || 'unknown',
1106
+ previousValue: serializeValue(c.prevValue),
1107
+ currentValue: serializeValue(c.value),
1108
+ count: 1,
1109
+ }))
1110
+ }
1111
+ if (changes.stateChanges && Array.isArray(changes.stateChanges)) {
1112
+ focusedComponentTracker.changes.stateChanges = changes.stateChanges.map((c: any) => ({
1113
+ name: c.name || `Hook ${c.index || 0}`,
1114
+ previousValue: serializeValue(c.prevValue),
1115
+ currentValue: serializeValue(c.value),
1116
+ count: 1,
1117
+ }))
1118
+ }
1119
+ if (changes.contextChanges && Array.isArray(changes.contextChanges)) {
1120
+ focusedComponentTracker.changes.contextChanges = changes.contextChanges.map((c: any) => ({
1121
+ name: c.name || 'Context',
1122
+ previousValue: serializeValue(c.prevValue),
1123
+ currentValue: serializeValue(c.value),
1124
+ count: 1,
1125
+ }))
1126
+ }
1127
+
1128
+ // Notify all callbacks
1129
+ const info: FocusedComponentRenderInfo = {
1130
+ componentName: focusedComponentTracker.componentName,
1131
+ renderCount: focusedComponentTracker.renderCount,
1132
+ changes: focusedComponentTracker.changes,
1133
+ timestamp: focusedComponentTracker.timestamp,
1134
+ }
1135
+
1136
+ focusedComponentChangeCallbacks.forEach((cb) => {
1137
+ try {
1138
+ cb(info)
1139
+ }
1140
+ catch {
1141
+ // Ignore callback errors
1142
+ }
1143
+ })
1144
+ }
1145
+ })
1146
+ }
1147
+ }
1148
+ }
1149
+ else if (state.kind === 'inspect-off') {
1150
+ // Don't clear the tracker here - it will be managed by setFocusedComponentByName
1151
+ // The tracker needs to persist to continue tracking renders after selection
1152
+ }
1153
+
1154
+ // Call the original callback
1155
+ callback(state)
1156
+ })
1157
+ return unsubscribe
1158
+ }
1159
+ return () => {}
1160
+ }
1161
+ catch {
1162
+ return () => {}
1163
+ }
1164
+ },
1165
+
1166
+ getFPS: () => fps,
1167
+
1168
+ getFocusedComponentRenderInfo: () => {
1169
+ if (!focusedComponentTracker)
1170
+ return null
1171
+
1172
+ return {
1173
+ componentName: focusedComponentTracker.componentName,
1174
+ renderCount: focusedComponentTracker.renderCount,
1175
+ changes: focusedComponentTracker.changes,
1176
+ timestamp: focusedComponentTracker.timestamp,
1177
+ }
1178
+ },
1179
+
1180
+ onFocusedComponentChange: (callback: (info: FocusedComponentRenderInfo) => void) => {
1181
+ focusedComponentChangeCallbacks.add(callback)
1182
+ return () => {
1183
+ focusedComponentChangeCallbacks.delete(callback)
1184
+ }
1185
+ },
1186
+
1187
+ /**
1188
+ * Set the focused component by name for render tracking
1189
+ * This is used when inspectState.kind is not 'focused' but we still want to track renders
1190
+ */
1191
+ setFocusedComponentByName: (componentName: string) => {
1192
+ // Clean up previous tracker
1193
+ if (focusedComponentTracker?.unsubscribe) {
1194
+ focusedComponentTracker.unsubscribe()
1195
+ }
1196
+
1197
+ // Create new tracker
1198
+ focusedComponentTracker = {
1199
+ componentName,
1200
+ renderCount: 0,
1201
+ changes: { propsChanges: [], stateChanges: [], contextChanges: [] },
1202
+ timestamp: Date.now(),
1203
+ unsubscribe: null,
1204
+ }
1205
+
1206
+ // Ensure onRender callback is set up for tracking
1207
+ if (!onRenderCleanup) {
1208
+ onRenderCleanup = setupOnRenderCallback()
1209
+ }
1210
+ },
1211
+
1212
+ clearFocusedComponentChanges: () => {
1213
+ if (focusedComponentTracker) {
1214
+ focusedComponentTracker.renderCount = 0
1215
+ focusedComponentTracker.changes = { propsChanges: [], stateChanges: [], contextChanges: [] }
1216
+ focusedComponentTracker.timestamp = Date.now()
1217
+ }
1218
+ },
1219
+
1220
+ /**
1221
+ * Get the component tree with render counts
1222
+ */
1223
+ getComponentTree: () => {
1224
+ return extractComponentTree()
1225
+ },
1226
+
1227
+ /**
1228
+ * Clear component render count tracking
1229
+ */
1230
+ clearComponentTree: () => {
1231
+ componentRenderCounts.clear()
1232
+ },
1233
+ }
1234
+ }
1235
+
1236
+ /**
1237
+ * Get or create the scan instance
1238
+ */
1239
+ export function getScanInstance(options?: ReactDevtoolsScanOptions): ScanInstance {
1240
+ if (!scanInstance && options) {
1241
+ scanInstance = createScanInstance(options)
1242
+ }
1243
+
1244
+ if (!scanInstance) {
1245
+ throw new Error('Scan instance not initialized. Call initScan first.')
1246
+ }
1247
+
1248
+ return scanInstance
1249
+ }
1250
+
1251
+ /**
1252
+ * Reset the scan instance
1253
+ */
1254
+ export function resetScanInstance(): void {
1255
+ scanInstance = null
1256
+ currentOptions = {}
1257
+ }