@samitouri / QOS-React-2 / commits / 61bd00498d

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 +};