@samitouri / QOS-React-1 / commits / 59ef3c4baf

[DevTools] Allow Introspection of React Elements and React.lazy (#34129)

With RSC it's common to get React.lazy objects in the children position. This first formats them nicely. Then it adds introspection support for both lazy and elements. Unfortunately because of quirks with the hydration mechanism we have to expose it under the name `_payload` instead of something direct. Also because the name "type" is taken we can't expose the type field on an element neither. That whole algorithm could use a rewrite. <img width="422" height="137" alt="Screenshot 2025-08-07 at 11 37 03 PM" src="https://github.com/user-attachments/assets/a6f65f58-dbc4-4b8f-928b-d7f629fc51b2" /> <img width="516" height="275" alt="Screenshot 2025-08-07 at 11 36 36 PM" src="https://github.com/user-attachments/assets/650bafdb-a633-4d78-9487-a750a18074ce" /> For JSX an alternative or additional feature might be instead to jump to the first Instance that was rendered using that JSX. We know that based on the equality of the memoizedProps on the Fiber. It's just a matter of whether we do that eagerly or more lazily when you click but you may not have a match so would be nice to indicate that before you click.

Sebastian Markbåge committed Aug 11, 2025 at 11:41 UTC 59ef3c4baf5fa107955eb72c3ee0f6e01a9923be
4 files changed +185 -25
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+20 -10
@@ -682,6 +682,7 @@ describe('InspectedElement', () => {
682 object_with_symbol={objectWithSymbol}
683 proxy={proxyInstance}
684 react_element={<span />}
685 + react_lazy={React.lazy(async () => ({default: 'foo'}))}
686 regexp={/abc/giu}
687 set={setShallow}
688 set_of_sets={setOfSets}
@@ -780,9 +781,18 @@ describe('InspectedElement', () => {
781 "preview_short": () => {},
782 "preview_long": () => {},
783 },
783 - "react_element": Dehydrated {
784 - "preview_short": <span />,
785 - "preview_long": <span />,
784 + "react_element": {
785 + "key": null,
786 + "props": Dehydrated {
787 + "preview_short": {…},
788 + "preview_long": {},
789 + },
790 + },
791 + "react_lazy": {
792 + "_payload": Dehydrated {
793 + "preview_short": {…},
794 + "preview_long": {_result: () => {}, _status: -1},
795 + },
796 },
797 "regexp": Dehydrated {
798 "preview_short": /abc/giu,
@@ -930,13 +940,13 @@ describe('InspectedElement', () => {
940 const inspectedElement = await inspectElementAtIndex(0);
941
942 expect(inspectedElement.props).toMatchInlineSnapshot(`
933 - {
934 - "unusedPromise": Dehydrated {
935 - "preview_short": Promise,
936 - "preview_long": Promise,
937 - },
938 - }
939 - `);
943 + {
944 + "unusedPromise": Dehydrated {
945 + "preview_short": Promise,
946 + "preview_long": Promise,
947 + },
948 + }
949 + `);
950 });
951
952 it('should not consume iterables while inspecting', async () => {
packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js
+7 -3
@@ -289,9 +289,13 @@ describe('InspectedElementContext', () => {
289 "preview_long": {boolean: true, number: 123, string: "abc"},
290 },
291 },
292 - "react_element": Dehydrated {
293 - "preview_short": <span />,
294 - "preview_long": <span />,
292 + "react_element": {
293 + "key": null,
294 + "props": Dehydrated {
295 + "preview_short": {…},
296 + "preview_long": {},
297 + },
298 + "ref": null,
299 },
300 "regexp": Dehydrated {
301 "preview_short": /abc/giu,
packages/react-devtools-shared/src/hydration.js
+95 -7
@@ -16,6 +16,8 @@ import {
16 setInObject,
17 } from 'react-devtools-shared/src/utils';
18
19 +import {REACT_LEGACY_ELEMENT_TYPE} from 'shared/ReactSymbols';
20 +
21 import type {
22 DehydratedData,
23 InspectedElementPath,
@@ -188,18 +190,103 @@ export function dehydrate(
190 type,
191 };
192
191 - // React Elements aren't very inspector-friendly,
192 - // and often contain private fields or circular references.
193 - case 'react_element':
194 - cleaned.push(path);
195 - return {
196 - inspectable: false,
193 + case 'react_element': {
194 + isPathAllowedCheck = isPathAllowed(path);
195 +
196 + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
197 + cleaned.push(path);
198 + return {
199 + inspectable: true,
200 + preview_short: formatDataForPreview(data, false),
201 + preview_long: formatDataForPreview(data, true),
202 + name: getDisplayNameForReactElement(data) || 'Unknown',
203 + type,
204 + };
205 + }
206 +
207 + const unserializableValue: Unserializable = {
208 + unserializable: true,
209 + type,
210 + readonly: true,
211 preview_short: formatDataForPreview(data, false),
212 preview_long: formatDataForPreview(data, true),
213 name: getDisplayNameForReactElement(data) || 'Unknown',
200 - type,
214 };
215 + // TODO: We can't expose type because that name is already taken on Unserializable.
216 + unserializableValue.key = dehydrate(
217 + data.key,
218 + cleaned,
219 + unserializable,
220 + path.concat(['key']),
221 + isPathAllowed,
222 + isPathAllowedCheck ? 1 : level + 1,
223 + );
224 + if (data.$$typeof === REACT_LEGACY_ELEMENT_TYPE) {
225 + unserializableValue.ref = dehydrate(
226 + data.ref,
227 + cleaned,
228 + unserializable,
229 + path.concat(['ref']),
230 + isPathAllowed,
231 + isPathAllowedCheck ? 1 : level + 1,
232 + );
233 + }
234 + unserializableValue.props = dehydrate(
235 + data.props,
236 + cleaned,
237 + unserializable,
238 + path.concat(['props']),
239 + isPathAllowed,
240 + isPathAllowedCheck ? 1 : level + 1,
241 + );
242
243 + unserializable.push(path);
244 + return unserializableValue;
245 + }
246 + case 'react_lazy': {
247 + isPathAllowedCheck = isPathAllowed(path);
248 +
249 + const payload = data._payload;
250 +
251 + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
252 + cleaned.push(path);
253 + const inspectable =
254 + payload !== null &&
255 + typeof payload === 'object' &&
256 + (payload._status === 1 ||
257 + payload._status === 2 ||
258 + payload.status === 'fulfilled' ||
259 + payload.status === 'rejected');
260 + return {
261 + inspectable,
262 + preview_short: formatDataForPreview(data, false),
263 + preview_long: formatDataForPreview(data, true),
264 + name: 'lazy()',
265 + type,
266 + };
267 + }
268 +
269 + const unserializableValue: Unserializable = {
270 + unserializable: true,
271 + type: type,
272 + preview_short: formatDataForPreview(data, false),
273 + preview_long: formatDataForPreview(data, true),
274 + name: 'lazy()',
275 + };
276 + // Ideally we should alias these properties to something more readable but
277 + // unfortunately because of how the hydration algorithm uses a single concept of
278 + // "path" we can't alias the path.
279 + unserializableValue._payload = dehydrate(
280 + payload,
281 + cleaned,
282 + unserializable,
283 + path.concat(['_payload']),
284 + isPathAllowed,
285 + isPathAllowedCheck ? 1 : level + 1,
286 + );
287 + unserializable.push(path);
288 + return unserializableValue;
289 + }
290 // ArrayBuffers error if you try to inspect them.
291 case 'array_buffer':
292 case 'data_view':
@@ -309,6 +396,7 @@ export function dehydrate(
396 isPathAllowedCheck = isPathAllowed(path);
397
398 if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
399 + cleaned.push(path);
400 return {
401 inspectable:
402 data.status === 'fulfilled' || data.status === 'rejected',
packages/react-devtools-shared/src/utils.js
+63 -5
@@ -633,6 +633,7 @@ export type DataType =
633 | 'thenable'
634 | 'object'
635 | 'react_element'
636 + | 'react_lazy'
637 | 'regexp'
638 | 'string'
639 | 'symbol'
@@ -686,11 +687,12 @@ export function getDataType(data: Object): DataType {
687 return 'number';
688 }
689 case 'object':
689 - if (
690 - data.$$typeof === REACT_ELEMENT_TYPE ||
691 - data.$$typeof === REACT_LEGACY_ELEMENT_TYPE
692 - ) {
693 - return 'react_element';
690 + switch (data.$$typeof) {
691 + case REACT_ELEMENT_TYPE:
692 + case REACT_LEGACY_ELEMENT_TYPE:
693 + return 'react_element';
694 + case REACT_LAZY_TYPE:
695 + return 'react_lazy';
696 }
697 if (isArray(data)) {
698 return 'array';
@@ -906,6 +908,62 @@ export function formatDataForPreview(
908 return `<${truncateForDisplay(
909 getDisplayNameForReactElement(data) || 'Unknown',
910 )} />`;
911 + case 'react_lazy':
912 + // To avoid actually initialize a lazy to cause a side-effect we make some assumptions
913 + // about the structure of the payload even though that's not really part of the contract.
914 + // In practice, this is really just coming from React.lazy helper or Flight.
915 + const payload = data._payload;
916 + if (payload !== null && typeof payload === 'object') {
917 + if (payload._status === 0) {
918 + // React.lazy constructor pending
919 + return `pending lazy()`;
920 + }
921 + if (payload._status === 1 && payload._result != null) {
922 + // React.lazy constructor fulfilled
923 + if (showFormattedValue) {
924 + const formatted = formatDataForPreview(
925 + payload._result.default,
926 + false,
927 + );
928 + return `fulfilled lazy() {${truncateForDisplay(formatted)}}`;
929 + } else {
930 + return `fulfilled lazy() {…}`;
931 + }
932 + }
933 + if (payload._status === 2) {
934 + // React.lazy constructor rejected
935 + if (showFormattedValue) {
936 + const formatted = formatDataForPreview(payload._result, false);
937 + return `rejected lazy() {${truncateForDisplay(formatted)}}`;
938 + } else {
939 + return `rejected lazy() {…}`;
940 + }
941 + }
942 + if (payload.status === 'pending' || payload.status === 'blocked') {
943 + // React Flight pending
944 + return `pending lazy()`;
945 + }
946 + if (payload.status === 'fulfilled') {
947 + // React Flight fulfilled
948 + if (showFormattedValue) {
949 + const formatted = formatDataForPreview(payload.value, false);
950 + return `fulfilled lazy() {${truncateForDisplay(formatted)}}`;
951 + } else {
952 + return `fulfilled lazy() {…}`;
953 + }
954 + }
955 + if (payload.status === 'rejected') {
956 + // React Flight rejected
957 + if (showFormattedValue) {
958 + const formatted = formatDataForPreview(payload.reason, false);
959 + return `rejected lazy() {${truncateForDisplay(formatted)}}`;
960 + } else {
961 + return `rejected lazy() {…}`;
962 + }
963 + }
964 + }
965 + // Some form of uninitialized
966 + return 'lazy()';
967 case 'array_buffer':
968 return `ArrayBuffer(${data.byteLength})`;
969 case 'data_view':