@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/.turbo/turbo-build.log +23 -0
- package/.turbo/turbo-prepare$colon$type.log +6 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/index.cjs +18394 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +304 -0
- package/dist/index.d.ts +304 -0
- package/dist/index.js +18350 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/adapter.ts +1257 -0
- package/src/index.ts +134 -0
- package/src/plugin.ts +638 -0
- package/src/types.ts +271 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +22 -0
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
|
+
}
|