refactor[devtools]: lazily define source for fiber based on component stacks (#28351)
`_debugSource` was removed in https://github.com/facebook/react/pull/28265. This PR migrates DevTools to define `source` for Fiber based on component stacks. This will be done lazily for inspected elements, once user clicks on the element in the tree. `DevToolsComponentStackFrame.js` was just copy-pasted from the implementation in `ReactComponentStackFrame`. Symbolication part is done in https://github.com/facebook/react/pull/28471 and stacked on this commit.
Ruslan Lesiutin committed
Mar 5, 2024 at 12:10 UTC
61bd00498d2a6e23885bac42f3aeb0e02cadb8eb
15 files changed
+446
-112
packages/react-devtools-core/src/standalone.js
+2
-2
@@ -144,7 +144,7 @@ function canViewElementSourceFunction(
144
145
const {source} = inspectedElement;
146
147
- return doesFilePathExist(source.fileName, projectRoots);
147
+ return doesFilePathExist(source.sourceURL, projectRoots);
148
}
149
150
function viewElementSourceFunction(
@@ -153,7 +153,7 @@ function viewElementSourceFunction(
153
): void {
154
const {source} = inspectedElement;
155
if (source !== null) {
156
- launchEditor(source.fileName, source.lineNumber, projectRoots);
156
+ launchEditor(source.sourceURL, source.line, projectRoots);
157
} else {
158
log.error('Cannot inspect element', id);
159
}
packages/react-devtools-inline/__tests__/__e2e__/components.test.js
+2
-3
@@ -92,15 +92,14 @@ test.describe('Components', () => {
92
? valueElement.value
93
: valueElement.innerText;
94
95
- return [name, value, source ? source.innerText : null];
95
+ return [name, value, source.innerText];
96
},
97
{name: isEditableName, value: isEditableValue}
98
);
99
100
expect(propName).toBe('label');
101
expect(propValue).toBe('"one"');
102
- expect(sourceText).toBe(null);
103
- // TODO: expect(sourceText).toMatch(/ListApp[a-zA-Z]*\.js/);
102
+ expect(sourceText).toMatch(/e2e-app[a-zA-Z]*\.js/);
103
});
104
105
test('should allow props to be edited', async () => {
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+9
-3
@@ -424,7 +424,9 @@ describe('InspectedElement', () => {
424
targetRenderCount = 0;
425
426
let inspectedElement = await inspectElementAtIndex(1);
427
- expect(targetRenderCount).toBe(1);
427
+ // One more because we call render function for generating component stack,
428
+ // which is required for defining source location
429
+ expect(targetRenderCount).toBe(2);
430
expect(inspectedElement.props).toMatchInlineSnapshot(`
431
{
432
"a": 1,
@@ -485,7 +487,9 @@ describe('InspectedElement', () => {
487
targetRenderCount = 0;
488
489
let inspectedElement = await inspectElementAtIndex(1);
488
- expect(targetRenderCount).toBe(1);
490
+ // One more because we call render function for generating component stack,
491
+ // which is required for defining source location
492
+ expect(targetRenderCount).toBe(2);
493
expect(inspectedElement.props).toMatchInlineSnapshot(`
494
{
495
"a": 1,
@@ -555,7 +559,9 @@ describe('InspectedElement', () => {
559
const inspectedElement = await inspectElementAtIndex(0);
560
561
expect(inspectedElement).not.toBe(null);
558
- expect(targetRenderCount).toBe(2);
562
+ // One more because we call render function for generating component stack,
563
+ // which is required for defining source location
564
+ expect(targetRenderCount).toBe(3);
565
expect(console.error).toHaveBeenCalledTimes(1);
566
expect(console.info).toHaveBeenCalledTimes(1);
567
expect(console.log).toHaveBeenCalledTimes(1);
packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js
+20
-23
@@ -224,11 +224,16 @@ describe('Store component filters', () => {
224
`);
225
});
226
227
+ // Disabled: filtering by path was removed, source is now determined lazily, including symbolication if applicable
228
// @reactVersion >= 16.0
228
- it('should filter by path', async () => {
229
- const Component = () => <div>Hi</div>;
229
+ xit('should filter by path', async () => {
230
+ // This component should use props object in order to throw for component stack generation
231
+ // See ReactComponentStackFrame:155 or DevToolsComponentStackFrame:147
232
+ const Component = props => {
233
+ return <div>{props.message}</div>;
234
+ };
235
231
- await actAsync(async () => render(<Component />));
236
+ await actAsync(async () => render(<Component message="Hi" />));
237
expect(store).toMatchInlineSnapshot(`
238
[root]
239
▾ <Component>
@@ -242,13 +247,7 @@ describe('Store component filters', () => {
247
]),
248
);
249
245
- // TODO: Filtering should work on component location.
246
- // expect(store).toMatchInlineSnapshot(`[root]`);
247
- expect(store).toMatchInlineSnapshot(`
248
- [root]
249
- ▾ <Component>
250
- <div>
251
- `);
250
+ expect(store).toMatchInlineSnapshot(`[root]`);
251
252
await actAsync(
253
async () =>
@@ -497,19 +496,17 @@ describe('Store component filters', () => {
496
]),
497
);
498
500
- utils.act(
501
- () =>
502
- utils.withErrorsOrWarningsIgnored(['test-only:'], () => {
503
- render(
504
- <React.Fragment>
505
- <ComponentWithError />
506
- <ComponentWithWarning />
507
- <ComponentWithWarningAndError />
508
- </React.Fragment>,
509
- );
510
- }),
511
- false,
512
- );
499
+ utils.withErrorsOrWarningsIgnored(['test-only:'], () => {
500
+ utils.act(() => {
501
+ render(
502
+ <React.Fragment>
503
+ <ComponentWithError />
504
+ <ComponentWithWarning />
505
+ <ComponentWithWarningAndError />
506
+ </React.Fragment>,
507
+ );
508
+ }, false);
509
+ });
510
511
expect(store).toMatchInlineSnapshot(``);
512
expect(store.errorCount).toBe(0);
packages/react-devtools-shared/src/__tests__/utils-test.js
+90
@@ -18,6 +18,7 @@ import {
18
formatWithStyles,
19
gt,
20
gte,
21
+ parseSourceFromComponentStack,
22
} from 'react-devtools-shared/src/backend/utils';
23
import {
24
REACT_SUSPENSE_LIST_TYPE as SuspenseList,
@@ -297,4 +298,93 @@ describe('utils', () => {
298
expect(isPlainObject(Object.create(null))).toBe(true);
299
});
300
});
301
+
302
+ describe('parseSourceFromComponentStack', () => {
303
+ it('should return null if passed empty string', () => {
304
+ expect(parseSourceFromComponentStack('')).toEqual(null);
305
+ });
306
+
307
+ it('should construct the source from the first frame if available', () => {
308
+ expect(
309
+ parseSourceFromComponentStack(
310
+ 'at l (https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js:1:10389)\n' +
311
+ 'at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)\n' +
312
+ 'at r (https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:498)\n',
313
+ ),
314
+ ).toEqual({
315
+ sourceURL:
316
+ 'https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js',
317
+ line: 1,
318
+ column: 10389,
319
+ });
320
+ });
321
+
322
+ it('should construct the source from highest available frame', () => {
323
+ expect(
324
+ parseSourceFromComponentStack(
325
+ ' at Q\n' +
326
+ ' at a\n' +
327
+ ' at m (https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236)\n' +
328
+ ' at div\n' +
329
+ ' at div\n' +
330
+ ' at div\n' +
331
+ ' at nav\n' +
332
+ ' at div\n' +
333
+ ' at te (https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:158857)\n' +
334
+ ' at tt (https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:165520)\n' +
335
+ ' at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)',
336
+ ),
337
+ ).toEqual({
338
+ sourceURL:
339
+ 'https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js',
340
+ line: 5,
341
+ column: 9236,
342
+ });
343
+ });
344
+
345
+ it('should construct the source from frame, which has only url specified', () => {
346
+ expect(
347
+ parseSourceFromComponentStack(
348
+ ' at Q\n' +
349
+ ' at a\n' +
350
+ ' at https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236\n',
351
+ ),
352
+ ).toEqual({
353
+ sourceURL:
354
+ 'https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js',
355
+ line: 5,
356
+ column: 9236,
357
+ });
358
+ });
359
+
360
+ it('should parse sourceURL correctly if it includes parentheses', () => {
361
+ expect(
362
+ parseSourceFromComponentStack(
363
+ 'at HotReload (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:307:11)\n' +
364
+ ' at Router (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js:181:11)\n' +
365
+ ' at ErrorBoundaryHandler (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js:114:9)',
366
+ ),
367
+ ).toEqual({
368
+ sourceURL:
369
+ 'webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js',
370
+ line: 307,
371
+ column: 11,
372
+ });
373
+ });
374
+
375
+ it('should support Firefox stack', () => {
376
+ expect(
377
+ parseSourceFromComponentStack(
378
+ 'tt@https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:165558\n' +
379
+ 'f@https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8535\n' +
380
+ 'r@https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:513',
381
+ ),
382
+ ).toEqual({
383
+ sourceURL:
384
+ 'https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js',
385
+ line: 1,
386
+ column: 165558,
387
+ });
388
+ });
389
+ });
390
});
packages/react-devtools-shared/src/backend/DevToolsComponentStackFrame.js
+138
-56
@@ -81,8 +81,6 @@ export function describeNativeComponentFrame(
81
}
82
}
83
84
- let control;
85
-
84
const previousPrepareStackTrace = Error.prepareStackTrace;
85
// $FlowFixMe[incompatible-type] It does accept undefined.
86
Error.prepareStackTrace = undefined;
@@ -98,64 +96,140 @@ export function describeNativeComponentFrame(
96
currentDispatcherRef.current = null;
97
disableLogs();
98
101
- try {
102
- // This should throw.
103
- if (construct) {
104
- // Something should be setting the props in the constructor.
105
- const Fake = function () {
106
- throw Error();
107
- };
108
- // $FlowFixMe[prop-missing]
109
- Object.defineProperty(Fake.prototype, 'props', {
110
- set: function () {
111
- // We use a throwing setter instead of frozen or non-writable props
112
- // because that won't throw in a non-strict mode function.
113
- throw Error();
114
- },
115
- });
116
- if (typeof Reflect === 'object' && Reflect.construct) {
117
- // We construct a different control for this case to include any extra
118
- // frames added by the construct call.
119
- try {
120
- Reflect.construct(Fake, []);
121
- } catch (x) {
122
- control = x;
99
+ // NOTE: keep in sync with the implementation in ReactComponentStackFrame
100
+
101
+ /**
102
+ * Finding a common stack frame between sample and control errors can be
103
+ * tricky given the different types and levels of stack trace truncation from
104
+ * different JS VMs. So instead we'll attempt to control what that common
105
+ * frame should be through this object method:
106
+ * Having both the sample and control errors be in the function under the
107
+ * `DescribeNativeComponentFrameRoot` property, + setting the `name` and
108
+ * `displayName` properties of the function ensures that a stack
109
+ * frame exists that has the method name `DescribeNativeComponentFrameRoot` in
110
+ * it for both control and sample stacks.
111
+ */
112
+ const RunInRootFrame = {
113
+ DetermineComponentFrameRoot(): [?string, ?string] {
114
+ let control;
115
+ try {
116
+ // This should throw.
117
+ if (construct) {
118
+ // Something should be setting the props in the constructor.
119
+ const Fake = function () {
120
+ throw Error();
121
+ };
122
+ // $FlowFixMe[prop-missing]
123
+ Object.defineProperty(Fake.prototype, 'props', {
124
+ set: function () {
125
+ // We use a throwing setter instead of frozen or non-writable props
126
+ // because that won't throw in a non-strict mode function.
127
+ throw Error();
128
+ },
129
+ });
130
+ if (typeof Reflect === 'object' && Reflect.construct) {
131
+ // We construct a different control for this case to include any extra
132
+ // frames added by the construct call.
133
+ try {
134
+ Reflect.construct(Fake, []);
135
+ } catch (x) {
136
+ control = x;
137
+ }
138
+ Reflect.construct(fn, [], Fake);
139
+ } else {
140
+ try {
141
+ Fake.call();
142
+ } catch (x) {
143
+ control = x;
144
+ }
145
+ // $FlowFixMe[prop-missing] found when upgrading Flow
146
+ fn.call(Fake.prototype);
147
+ }
148
+ } else {
149
+ try {
150
+ throw Error();
151
+ } catch (x) {
152
+ control = x;
153
+ }
154
+ // TODO(luna): This will currently only throw if the function component
155
+ // tries to access React/ReactDOM/props. We should probably make this throw
156
+ // in simple components too
157
+ const maybePromise = fn();
158
+
159
+ // If the function component returns a promise, it's likely an async
160
+ // component, which we don't yet support. Attach a noop catch handler to
161
+ // silence the error.
162
+ // TODO: Implement component stacks for async client components?
163
+ if (maybePromise && typeof maybePromise.catch === 'function') {
164
+ maybePromise.catch(() => {});
165
+ }
166
}
124
- Reflect.construct(fn, [], Fake);
125
- } else {
126
- try {
127
- Fake.call();
128
- } catch (x) {
129
- control = x;
167
+ } catch (sample) {
168
+ // This is inlined manually because closure doesn't do it for us.
169
+ if (sample && control && typeof sample.stack === 'string') {
170
+ return [sample.stack, control.stack];
171
}
131
- // $FlowFixMe[prop-missing] found when upgrading Flow
132
- fn.call(Fake.prototype);
133
- }
134
- } else {
135
- try {
136
- throw Error();
137
- } catch (x) {
138
- control = x;
172
}
140
- fn();
141
- }
142
- } catch (sample) {
143
- // This is inlined manually because closure doesn't do it for us.
144
- if (sample && control && typeof sample.stack === 'string') {
173
+ return [null, null];
174
+ },
175
+ };
176
+ // $FlowFixMe[prop-missing]
177
+ RunInRootFrame.DetermineComponentFrameRoot.displayName =
178
+ 'DetermineComponentFrameRoot';
179
+ const namePropDescriptor = Object.getOwnPropertyDescriptor(
180
+ RunInRootFrame.DetermineComponentFrameRoot,
181
+ 'name',
182
+ );
183
+ // Before ES6, the `name` property was not configurable.
184
+ if (namePropDescriptor && namePropDescriptor.configurable) {
185
+ // V8 utilizes a function's `name` property when generating a stack trace.
186
+ Object.defineProperty(
187
+ RunInRootFrame.DetermineComponentFrameRoot,
188
+ // Configurable properties can be updated even if its writable descriptor
189
+ // is set to `false`.
190
+ // $FlowFixMe[cannot-write]
191
+ 'name',
192
+ {value: 'DetermineComponentFrameRoot'},
193
+ );
194
+ }
195
+
196
+ try {
197
+ const [sampleStack, controlStack] =
198
+ RunInRootFrame.DetermineComponentFrameRoot();
199
+ if (sampleStack && controlStack) {
200
// This extracts the first frame from the sample that isn't also in the control.
201
// Skipping one frame that we assume is the frame that calls the two.
147
- const sampleLines = sample.stack.split('\n');
148
- const controlLines = control.stack.split('\n');
149
- let s = sampleLines.length - 1;
150
- let c = controlLines.length - 1;
151
- while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
152
- // We expect at least one stack frame to be shared.
153
- // Typically this will be the root most one. However, stack frames may be
154
- // cut off due to maximum stack limits. In this case, one maybe cut off
155
- // earlier than the other. We assume that the sample is longer or the same
156
- // and there for cut off earlier. So we should find the root most frame in
157
- // the sample somewhere in the control.
158
- c--;
202
+ const sampleLines = sampleStack.split('\n');
203
+ const controlLines = controlStack.split('\n');
204
+ let s = 0;
205
+ let c = 0;
206
+ while (
207
+ s < sampleLines.length &&
208
+ !sampleLines[s].includes('DetermineComponentFrameRoot')
209
+ ) {
210
+ s++;
211
+ }
212
+ while (
213
+ c < controlLines.length &&
214
+ !controlLines[c].includes('DetermineComponentFrameRoot')
215
+ ) {
216
+ c++;
217
+ }
218
+ // We couldn't find our intentionally injected common root frame, attempt
219
+ // to find another common root frame by search from the bottom of the
220
+ // control stack...
221
+ if (s === sampleLines.length || c === controlLines.length) {
222
+ s = sampleLines.length - 1;
223
+ c = controlLines.length - 1;
224
+ while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
225
+ // We expect at least one stack frame to be shared.
226
+ // Typically this will be the root most one. However, stack frames may be
227
+ // cut off due to maximum stack limits. In this case, one maybe cut off
228
+ // earlier than the other. We assume that the sample is longer or the same
229
+ // and there for cut off earlier. So we should find the root most frame in
230
+ // the sample somewhere in the control.
231
+ c--;
232
+ }
233
}
234
for (; s >= 1 && c >= 0; s--, c--) {
235
// Next we find the first one that isn't the same which should be the
@@ -174,7 +248,15 @@ export function describeNativeComponentFrame(
248
// The next one that isn't the same should be our match though.
249
if (c < 0 || sampleLines[s] !== controlLines[c]) {
250
// V8 adds a "new" prefix for native classes. Let's remove it to make it prettier.
177
- const frame = '\n' + sampleLines[s].replace(' at new ', ' at ');
251
+ let frame = '\n' + sampleLines[s].replace(' at new ', ' at ');
252
+
253
+ // If our component frame is labeled "<anonymous>"
254
+ // but we have a user-provided "displayName"
255
+ // splice it in to make the stack more readable.
256
+ if (fn.displayName && frame.includes('<anonymous>')) {
257
+ frame = frame.replace('<anonymous>', fn.displayName);
258
+ }
259
+
260
if (__DEV__) {
261
if (typeof fn === 'function') {
262
componentFrameCache.set(fn, frame);
packages/react-devtools-shared/src/backend/legacy/renderer.js
+1
@@ -828,6 +828,7 @@ export function attach(
828
829
// Can view component source location.
830
canViewSource: type === ElementTypeClass || type === ElementTypeFunction,
831
+ source: null,
832
833
// Only legacy context exists in legacy versions.
834
hasLegacyContext: true,
packages/react-devtools-shared/src/backend/renderer.js
+58
-9
@@ -42,6 +42,7 @@ import {sessionStorageGetItem} from 'react-devtools-shared/src/storage';
42
import {
43
gt,
44
gte,
45
+ parseSourceFromComponentStack,
46
serializeToString,
47
} from 'react-devtools-shared/src/backend/utils';
48
import {
@@ -124,6 +125,8 @@ import type {
125
ElementType,
126
Plugins,
127
} from 'react-devtools-shared/src/frontend/types';
128
+import type {Source} from 'react-devtools-shared/src/shared/types';
129
+import {getStackByFiberInDevAndProd} from './DevToolsFiberComponentStack';
130
131
type getDisplayNameForFiberType = (fiber: Fiber) => string | null;
132
type getTypeSymbolType = (type: any) => symbol | number;
@@ -585,6 +588,8 @@ const fiberToIDMap: Map<Fiber, number> = new Map();
588
// operations that should be the same whether the current and work-in-progress Fiber is used.
589
const idToArbitraryFiberMap: Map<number, Fiber> = new Map();
590
591
+const fiberToComponentStackMap: WeakMap<Fiber, string> = new WeakMap();
592
+
593
export function attach(
594
hook: DevToolsHook,
595
rendererID: number,
@@ -1029,15 +1034,21 @@ export function attach(
1034
}
1035
}
1036
1032
- // TODO: Figure out a way to filter by path in the new model which has no debug info.
1033
- // if (hideElementsWithPaths.size > 0) {
1034
- // const {fileName} = ...;
1035
- // for (const pathRegExp of hideElementsWithPaths) {
1036
- // if (pathRegExp.test(fileName)) {
1037
- // return true;
1038
- // }
1039
- // }
1040
- // }
1037
+ /* DISABLED: https://github.com/facebook/react/pull/28417
1038
+ if (hideElementsWithPaths.size > 0) {
1039
+ const source = getSourceForFiber(fiber);
1040
+
1041
+ if (source != null) {
1042
+ const {fileName} = source;
1043
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
1044
+ for (const pathRegExp of hideElementsWithPaths) {
1045
+ if (pathRegExp.test(fileName)) {
1046
+ return true;
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ */
1052
1053
return false;
1054
}
@@ -1246,10 +1257,12 @@ export function attach(
1257
}
1258
1259
fiberToIDMap.delete(fiber);
1260
+ fiberToComponentStackMap.delete(fiber);
1261
1262
const {alternate} = fiber;
1263
if (alternate !== null) {
1264
fiberToIDMap.delete(alternate);
1265
+ fiberToComponentStackMap.delete(alternate);
1266
}
1267
1268
if (forceErrorForFiberIDs.has(fiberID)) {
@@ -3361,6 +3374,11 @@ export function attach(
3374
}
3375
}
3376
3377
+ let source = null;
3378
+ if (canViewSource) {
3379
+ source = getSourceForFiber(fiber);
3380
+ }
3381
+
3382
return {
3383
id,
3384
@@ -3393,6 +3411,7 @@ export function attach(
3411
3412
// Can view component source location.
3413
canViewSource,
3414
+ source,
3415
3416
// Does the component have legacy context attached to it.
3417
hasLegacyContext,
@@ -4520,6 +4539,34 @@ export function attach(
4539
return idToArbitraryFiberMap.has(id);
4540
}
4541
4542
+ function getComponentStackForFiber(fiber: Fiber): string | null {
4543
+ let componentStack = fiberToComponentStackMap.get(fiber);
4544
+ if (componentStack == null) {
4545
+ const dispatcherRef = renderer.currentDispatcherRef;
4546
+ if (dispatcherRef == null) {
4547
+ return null;
4548
+ }
4549
+
4550
+ componentStack = getStackByFiberInDevAndProd(
4551
+ ReactTypeOfWork,
4552
+ fiber,
4553
+ dispatcherRef,
4554
+ );
4555
+ fiberToComponentStackMap.set(fiber, componentStack);
4556
+ }
4557
+
4558
+ return componentStack;
4559
+ }
4560
+
4561
+ function getSourceForFiber(fiber: Fiber): Source | null {
4562
+ const componentStack = getComponentStackForFiber(fiber);
4563
+ if (componentStack == null) {
4564
+ return null;
4565
+ }
4566
+
4567
+ return parseSourceFromComponentStack(componentStack);
4568
+ }
4569
+
4570
return {
4571
cleanup,
4572
clearErrorsAndWarnings,
@@ -4530,6 +4577,8 @@ export function attach(
4577
findNativeNodesForFiberID,
4578
flushInitialOperations,
4579
getBestMatchForTrackedPath,
4580
+ getComponentStackForFiber,
4581
+ getSourceForFiber,
4582
getDisplayNameForFiberID,
4583
getFiberForNative,
4584
getFiberIDForNative,
packages/react-devtools-shared/src/backend/types.js
+2
@@ -29,6 +29,7 @@ import type {InitBackend} from 'react-devtools-shared/src/backend';
29
import type {TimelineDataExport} from 'react-devtools-timeline/src/types';
30
import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types';
31
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
32
+import type {Source} from 'react-devtools-shared/src/shared/types';
33
import type Agent from './agent';
34
35
type BundleType =
@@ -278,6 +279,7 @@ export type InspectedElement = {
279
280
// List of owners
281
owners: Array<SerializedElement> | null,
282
+ source: Source | null,
283
284
type: ElementType,
285
packages/react-devtools-shared/src/backend/utils.js
+90
@@ -12,6 +12,7 @@ import {compareVersions} from 'compare-versions';
12
import {dehydrate} from '../hydration';
13
import isArray from 'shared/isArray';
14
15
+import type {Source} from 'react-devtools-shared/src/shared/types';
16
import type {DehydratedData} from 'react-devtools-shared/src/frontend/types';
17
18
// TODO: update this to the first React version that has a corresponding DevTools backend
@@ -289,3 +290,92 @@ export const isReactNativeEnvironment = (): boolean => {
290
// We should probably define the client for DevTools on the backend side and share it with the frontend
291
return window.document == null;
292
};
293
+
294
+function extractLocation(
295
+ url: string,
296
+): null | {sourceURL: string, line?: string, column?: string} {
297
+ if (url.indexOf(':') === -1) {
298
+ return null;
299
+ }
300
+
301
+ // remove any parentheses from start and end
302
+ const withoutParentheses = url.replace(/^\(+/, '').replace(/\)+$/, '');
303
+ const locationParts = /(at )?(.+?)(?::(\d+))?(?::(\d+))?$/.exec(
304
+ withoutParentheses,
305
+ );
306
+
307
+ if (locationParts == null) {
308
+ return null;
309
+ }
310
+
311
+ const [, , sourceURL, line, column] = locationParts;
312
+ return {sourceURL, line, column};
313
+}
314
+
315
+const CHROME_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
316
+function parseSourceFromChromeStack(stack: string): Source | null {
317
+ const frames = stack.split('\n');
318
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
319
+ for (const frame of frames) {
320
+ const sanitizedFrame = frame.trim();
321
+
322
+ const locationInParenthesesMatch = sanitizedFrame.match(/ (\(.+\)$)/);
323
+ const possibleLocation = locationInParenthesesMatch
324
+ ? locationInParenthesesMatch[1]
325
+ : sanitizedFrame;
326
+
327
+ const location = extractLocation(possibleLocation);
328
+ // Continue the search until at least sourceURL is found
329
+ if (location == null) {
330
+ continue;
331
+ }
332
+
333
+ const {sourceURL, line = '1', column = '1'} = location;
334
+
335
+ return {
336
+ sourceURL,
337
+ line: parseInt(line, 10),
338
+ column: parseInt(column, 10),
339
+ };
340
+ }
341
+
342
+ return null;
343
+}
344
+
345
+function parseSourceFromFirefoxStack(stack: string): Source | null {
346
+ const frames = stack.split('\n');
347
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
348
+ for (const frame of frames) {
349
+ const sanitizedFrame = frame.trim();
350
+ const frameWithoutFunctionName = sanitizedFrame.replace(
351
+ /((.*".+"[^@]*)?[^@]*)(?:@)/,
352
+ '',
353
+ );
354
+
355
+ const location = extractLocation(frameWithoutFunctionName);
356
+ // Continue the search until at least sourceURL is found
357
+ if (location == null) {
358
+ continue;
359
+ }
360
+
361
+ const {sourceURL, line = '1', column = '1'} = location;
362
+
363
+ return {
364
+ sourceURL,
365
+ line: parseInt(line, 10),
366
+ column: parseInt(column, 10),
367
+ };
368
+ }
369
+
370
+ return null;
371
+}
372
+
373
+export function parseSourceFromComponentStack(
374
+ componentStack: string,
375
+): Source | null {
376
+ if (componentStack.match(CHROME_STACK_REGEXP)) {
377
+ return parseSourceFromChromeStack(componentStack);
378
+ }
379
+
380
+ return parseSourceFromFirefoxStack(componentStack);
381
+}
packages/react-devtools-shared/src/backendAPI.js
+2
-1
@@ -228,6 +228,7 @@ export function convertInspectedElementBackendToFrontend(
228
id,
229
type,
230
owners,
231
+ source,
232
context,
233
hooks,
234
plugins,
@@ -260,7 +261,7 @@ export function convertInspectedElementBackendToFrontend(
261
rendererPackageName,
262
rendererVersion,
263
rootType,
263
- source: null, // TODO: Load source location lazily.
264
+ source,
265
type,
266
owners:
267
owners === null
packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js
+4
-4
@@ -220,10 +220,10 @@ export default function InspectedElementWrapper(_: Props): React.Node {
220
221
const url = new URL(editorURL);
222
url.href = url.href
223
- .replace('{path}', source.fileName)
224
- .replace('{line}', String(source.lineNumber))
225
- .replace('%7Bpath%7D', source.fileName)
226
- .replace('%7Bline%7D', String(source.lineNumber));
223
+ .replace('{path}', source.sourceURL)
224
+ .replace('{line}', String(source.line))
225
+ .replace('%7Bpath%7D', source.sourceURL)
226
+ .replace('%7Bline%7D', String(source.line));
227
window.open(url);
228
}, [inspectedElement, editorURL]);
229
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js
+12
-10
@@ -172,7 +172,7 @@ export default function InspectedElementView({
172
)}
173
174
{source !== null && (
175
- <Source fileName={source.fileName} lineNumber={source.lineNumber} />
175
+ <Source sourceURL={source.sourceURL} line={source.line} />
176
)}
177
</div>
178
@@ -239,15 +239,17 @@ export default function InspectedElementView({
239
}
240
241
// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame
242
-function formatSourceForDisplay(fileName: string, lineNumber: string) {
242
+function formatSourceForDisplay(sourceURL: string, line: number) {
243
+ // Note: this RegExp doesn't work well with URLs from Metro,
244
+ // which provides bundle URL with query parameters prefixed with /&
245
const BEFORE_SLASH_RE = /^(.*)[\\\/]/;
246
245
- let nameOnly = fileName.replace(BEFORE_SLASH_RE, '');
247
+ let nameOnly = sourceURL.replace(BEFORE_SLASH_RE, '');
248
249
// In DEV, include code for a common special case:
250
// prefer "folder/index.js" instead of just "index.js".
251
if (/^index\./.test(nameOnly)) {
250
- const match = fileName.match(BEFORE_SLASH_RE);
252
+ const match = sourceURL.match(BEFORE_SLASH_RE);
253
if (match) {
254
const pathBeforeSlash = match[1];
255
if (pathBeforeSlash) {
@@ -257,16 +259,16 @@ function formatSourceForDisplay(fileName: string, lineNumber: string) {
259
}
260
}
261
260
- return `${nameOnly}:${lineNumber}`;
262
+ return `${nameOnly}:${sourceURL}`;
263
}
264
265
type SourceProps = {
264
- fileName: string,
265
- lineNumber: string,
266
+ sourceURL: string,
267
+ line: number,
268
};
269
268
-function Source({fileName, lineNumber}: SourceProps) {
269
- const handleCopy = () => copy(`${fileName}:${lineNumber}`);
270
+function Source({sourceURL, line}: SourceProps) {
271
+ const handleCopy = () => copy(`${sourceURL}:${line}`);
272
return (
273
<div className={styles.Source} data-testname="InspectedElementView-Source">
274
<div className={styles.SourceHeaderRow}>
@@ -276,7 +278,7 @@ function Source({fileName, lineNumber}: SourceProps) {
278
</Button>
279
</div>
280
<div className={styles.SourceOneLiner}>
279
- {formatSourceForDisplay(fileName, lineNumber)}
281
+ {formatSourceForDisplay(sourceURL, line)}
282
</div>
283
</div>
284
);
packages/react-devtools-shared/src/frontend/types.js
+2
-1
@@ -18,6 +18,7 @@ import type {
18
Dehydrated,
19
Unserializable,
20
} from 'react-devtools-shared/src/hydration';
21
+import type {Source} from 'react-devtools-shared/src/shared/types';
22
23
export type BrowserTheme = 'dark' | 'light';
24
@@ -219,7 +220,7 @@ export type InspectedElement = {
220
owners: Array<SerializedElement> | null,
221
222
// Location of component in source code.
222
- source: null, // TODO: Reinstate a way to load this lazily.
223
+ source: Source | null,
224
225
type: ElementType,
226
packages/react-devtools-shared/src/shared/types.js
new
+14
@@ -0,0 +1,14 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+export type Source = {
11
+ sourceURL: string,
12
+ line: number,
13
+ column: number,
14
+};