@samitouri / QOS-React-2 / commits / 197d6a0403

[devtools] 1st class support of used Thenables (#32989)

Co-authored-by: Ruslan Lesiutin <rdlesyutin@gmail.com>

Sebastian "Sebbie" Silbermann committed Apr 24, 2025 at 13:46 UTC 197d6a040368ee87308225f5534b013cb5512673
6 files changed +306 -3
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+124
@@ -815,6 +815,130 @@ describe('InspectedElement', () => {
815 `);
816 });
817
818 + it('should support Thenables in React 19', async () => {
819 + const Example = () => null;
820 +
821 + class SubclassedPromise extends Promise {}
822 +
823 + const plainThenable = {then() {}};
824 + const subclassedPromise = new SubclassedPromise(() => {});
825 + const unusedPromise = Promise.resolve();
826 + const usedFulfilledPromise = Promise.resolve();
827 + const usedFulfilledRichPromise = Promise.resolve({
828 + some: {
829 + deeply: {
830 + nested: {
831 + object: {
832 + string: 'test',
833 + fn: () => {},
834 + },
835 + },
836 + },
837 + },
838 + });
839 + const usedPendingPromise = new Promise(resolve => {});
840 + const usedRejectedPromise = Promise.reject(
841 + new Error('test-error-do-not-surface'),
842 + );
843 +
844 + function Use({value}) {
845 + React.use(value);
846 + }
847 +
848 + await utils.actAsync(() =>
849 + render(
850 + <>
851 + <Example
852 + plainThenable={plainThenable}
853 + subclassedPromise={subclassedPromise}
854 + unusedPromise={unusedPromise}
855 + usedFulfilledPromise={usedFulfilledPromise}
856 + usedFulfilledRichPromise={usedFulfilledRichPromise}
857 + usedPendingPromise={usedPendingPromise}
858 + usedRejectedPromise={usedRejectedPromise}
859 + />
860 + <React.Suspense>
861 + <Use value={usedPendingPromise} />
862 + </React.Suspense>
863 + <React.Suspense>
864 + <Use value={usedFulfilledPromise} />
865 + </React.Suspense>
866 + <React.Suspense>
867 + <Use value={usedFulfilledRichPromise} />
868 + </React.Suspense>
869 + <ErrorBoundary>
870 + <React.Suspense>
871 + <Use value={usedRejectedPromise} />
872 + </React.Suspense>
873 + </ErrorBoundary>
874 + </>,
875 + ),
876 + );
877 +
878 + const inspectedElement = await inspectElementAtIndex(0);
879 +
880 + expect(inspectedElement.props).toMatchInlineSnapshot(`
881 + {
882 + "plainThenable": Dehydrated {
883 + "preview_short": Thenable,
884 + "preview_long": Thenable,
885 + },
886 + "subclassedPromise": Dehydrated {
887 + "preview_short": SubclassedPromise,
888 + "preview_long": SubclassedPromise,
889 + },
890 + "unusedPromise": Dehydrated {
891 + "preview_short": Promise,
892 + "preview_long": Promise,
893 + },
894 + "usedFulfilledPromise": {
895 + "value": undefined,
896 + },
897 + "usedFulfilledRichPromise": {
898 + "value": Dehydrated {
899 + "preview_short": {…},
900 + "preview_long": {some: {…}},
901 + },
902 + },
903 + "usedPendingPromise": Dehydrated {
904 + "preview_short": pending Promise,
905 + "preview_long": pending Promise,
906 + },
907 + "usedRejectedPromise": {
908 + "reason": Dehydrated {
909 + "preview_short": Error,
910 + "preview_long": Error,
911 + },
912 + },
913 + }
914 + `);
915 + });
916 +
917 + it('should support Promises in React 18', async () => {
918 + const Example = () => null;
919 +
920 + const unusedPromise = Promise.resolve();
921 +
922 + await utils.actAsync(() =>
923 + render(
924 + <>
925 + <Example unusedPromise={unusedPromise} />
926 + </>,
927 + ),
928 + );
929 +
930 + const inspectedElement = await inspectElementAtIndex(0);
931 +
932 + expect(inspectedElement.props).toMatchInlineSnapshot(`
933 + {
934 + "unusedPromise": Dehydrated {
935 + "preview_short": Promise,
936 + "preview_long": Promise,
937 + },
938 + }
939 + `);
940 + });
941 +
942 it('should not consume iterables while inspecting', async () => {
943 const Example = () => null;
944
packages/react-devtools-shared/src/hydration.js
+71 -1
@@ -43,7 +43,7 @@ export type Dehydrated = {
43 type: string,
44 };
45
46 -// Typed arrays and other complex iteratable objects (e.g. Map, Set, ImmutableJS) need special handling.
46 +// Typed arrays, other complex iteratable objects (e.g. Map, Set, ImmutableJS) or Promises need special handling.
47 // These objects can't be serialized without losing type information,
48 // so a "Unserializable" type wrapper is used (with meta-data keys) to send nested values-
49 // while preserving the original type and name.
@@ -303,6 +303,76 @@ export function dehydrate(
303 type,
304 };
305
306 + case 'thenable':
307 + isPathAllowedCheck = isPathAllowed(path);
308 +
309 + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
310 + return {
311 + inspectable:
312 + data.status === 'fulfilled' || data.status === 'rejected',
313 + preview_short: formatDataForPreview(data, false),
314 + preview_long: formatDataForPreview(data, true),
315 + name: data.toString(),
316 + type,
317 + };
318 + }
319 +
320 + switch (data.status) {
321 + case 'fulfilled': {
322 + const unserializableValue: Unserializable = {
323 + unserializable: true,
324 + type: type,
325 + preview_short: formatDataForPreview(data, false),
326 + preview_long: formatDataForPreview(data, true),
327 + name: 'fulfilled Thenable',
328 + };
329 +
330 + unserializableValue.value = dehydrate(
331 + data.value,
332 + cleaned,
333 + unserializable,
334 + path.concat(['value']),
335 + isPathAllowed,
336 + isPathAllowedCheck ? 1 : level + 1,
337 + );
338 +
339 + unserializable.push(path);
340 +
341 + return unserializableValue;
342 + }
343 + case 'rejected': {
344 + const unserializableValue: Unserializable = {
345 + unserializable: true,
346 + type: type,
347 + preview_short: formatDataForPreview(data, false),
348 + preview_long: formatDataForPreview(data, true),
349 + name: 'rejected Thenable',
350 + };
351 +
352 + unserializableValue.reason = dehydrate(
353 + data.reason,
354 + cleaned,
355 + unserializable,
356 + path.concat(['reason']),
357 + isPathAllowed,
358 + isPathAllowedCheck ? 1 : level + 1,
359 + );
360 +
361 + unserializable.push(path);
362 +
363 + return unserializableValue;
364 + }
365 + default:
366 + cleaned.push(path);
367 + return {
368 + inspectable: false,
369 + preview_short: formatDataForPreview(data, false),
370 + preview_long: formatDataForPreview(data, true),
371 + name: data.toString(),
372 + type,
373 + };
374 + }
375 +
376 case 'object':
377 isPathAllowedCheck = isPathAllowed(path);
378
packages/react-devtools-shared/src/utils.js
+40 -1
@@ -563,6 +563,7 @@ export type DataType =
563 | 'nan'
564 | 'null'
565 | 'number'
566 + | 'thenable'
567 | 'object'
568 | 'react_element'
569 | 'regexp'
@@ -631,6 +632,8 @@ export function getDataType(data: Object): DataType {
632 }
633 } else if (data.constructor && data.constructor.name === 'RegExp') {
634 return 'regexp';
635 + } else if (typeof data.then === 'function') {
636 + return 'thenable';
637 } else {
638 // $FlowFixMe[method-unbinding]
639 const toStringValue = Object.prototype.toString.call(data);
@@ -934,6 +937,42 @@ export function formatDataForPreview(
937 } catch (error) {
938 return 'unserializable';
939 }
940 + case 'thenable':
941 + let displayName: string;
942 + if (isPlainObject(data)) {
943 + displayName = 'Thenable';
944 + } else {
945 + let resolvedConstructorName = data.constructor.name;
946 + if (typeof resolvedConstructorName !== 'string') {
947 + resolvedConstructorName =
948 + Object.getPrototypeOf(data).constructor.name;
949 + }
950 + if (typeof resolvedConstructorName === 'string') {
951 + displayName = resolvedConstructorName;
952 + } else {
953 + displayName = 'Thenable';
954 + }
955 + }
956 + switch (data.status) {
957 + case 'pending':
958 + return `pending ${displayName}`;
959 + case 'fulfilled':
960 + if (showFormattedValue) {
961 + const formatted = formatDataForPreview(data.value, false);
962 + return `fulfilled ${displayName} {${truncateForDisplay(formatted)}}`;
963 + } else {
964 + return `fulfilled ${displayName} {…}`;
965 + }
966 + case 'rejected':
967 + if (showFormattedValue) {
968 + const formatted = formatDataForPreview(data.reason, false);
969 + return `rejected ${displayName} {${truncateForDisplay(formatted)}}`;
970 + } else {
971 + return `rejected ${displayName} {…}`;
972 + }
973 + default:
974 + return displayName;
975 + }
976 case 'object':
977 if (showFormattedValue) {
978 const keys = Array.from(getAllEnumerableKeys(data)).sort(alphaSortKeys);
@@ -963,7 +1002,7 @@ export function formatDataForPreview(
1002 case 'nan':
1003 case 'null':
1004 case 'undefined':
966 - return data;
1005 + return String(data);
1006 default:
1007 try {
1008 return truncateForDisplay(String(data));
packages/react-devtools-shell/README.md
+1 -1
@@ -2,7 +2,7 @@ Harness for testing local changes to the `react-devtools-inline` and `react-devt
2
3 ## Development
4
5 -This target should be run in parallel with the `react-devtools-inline` package. The first step then is to run that target following the instructions in the [`react-devtools-inline` README's local development section](https://github.com/facebook/react/tree/main/packages/react-devtools-inline#local-development).
5 +This target should be run in parallel with the `react-devtools-inline` package. The first step then is to run that target following the instructions in the [`react-devtools-inline` README's local development section](../react-devtools-inline/README.md#local-development).
6
7 The test harness can then be run as follows:
8 ```sh
packages/react-devtools-shell/src/app/Hydration/index.js
+62
@@ -49,6 +49,10 @@ const objectOfObjects = {
49 j: 9,
50 },
51 qux: {},
52 + quux: {
53 + k: undefined,
54 + l: null,
55 + },
56 };
57
58 function useOuterFoo() {
@@ -106,6 +110,26 @@ function useInnerBaz() {
110 return count;
111 }
112
113 +const unusedPromise = Promise.resolve();
114 +const usedFulfilledPromise = Promise.resolve();
115 +const usedFulfilledRichPromise = Promise.resolve({
116 + some: {
117 + deeply: {
118 + nested: {
119 + object: {
120 + string: 'test',
121 + fn: () => {},
122 + },
123 + },
124 + },
125 + },
126 +});
127 +const usedPendingPromise = new Promise(resolve => {});
128 +const usedRejectedPromise = Promise.reject(
129 + // eslint-disable-next-line react-internal/prod-error-codes
130 + new Error('test-error-do-not-surface'),
131 +);
132 +
133 export default function Hydration(): React.Node {
134 return (
135 <Fragment>
@@ -120,17 +144,55 @@ export default function Hydration(): React.Node {
144 date={new Date()}
145 array={arrayOfArrays}
146 object={objectOfObjects}
147 + unusedPromise={unusedPromise}
148 + usedFulfilledPromise={usedFulfilledPromise}
149 + usedFulfilledRichPromise={usedFulfilledRichPromise}
150 + usedPendingPromise={usedPendingPromise}
151 + usedRejectedPromise={usedRejectedPromise}
152 />
153 <DeepHooks />
154 </Fragment>
155 );
156 }
157
158 +function Use({value}: {value: Promise<mixed>}): React.Node {
159 + React.use(value);
160 + return null;
161 +}
162 +
163 +class IgnoreErrors extends React.Component {
164 + state: {hasError: boolean} = {hasError: false};
165 + static getDerivedStateFromError(): {hasError: boolean} {
166 + return {hasError: true};
167 + }
168 +
169 + render(): React.Node {
170 + if (this.state.hasError) {
171 + return null;
172 + }
173 + return this.props.children;
174 + }
175 +}
176 +
177 function DehydratableProps({array, object}: any) {
178 return (
179 <ul>
180 <li>array: {JSON.stringify(array, null, 2)}</li>
181 <li>object: {JSON.stringify(object, null, 2)}</li>
182 + <React.Suspense>
183 + <Use value={usedPendingPromise} />
184 + </React.Suspense>
185 + <React.Suspense>
186 + <Use value={usedFulfilledPromise} />
187 + </React.Suspense>
188 + <React.Suspense>
189 + <Use value={usedFulfilledRichPromise} />
190 + </React.Suspense>
191 + <IgnoreErrors>
192 + <React.Suspense>
193 + <Use value={usedRejectedPromise} />
194 + </React.Suspense>
195 + </IgnoreErrors>
196 </ul>
197 );
198 }
packages/react-devtools-shell/webpack-server.js
+8
@@ -176,6 +176,14 @@ const appServer = new WebpackDevServer(
176 logging: 'warn',
177 overlay: {
178 warnings: false,
179 + runtimeErrors: error => {
180 + const shouldIgnoreError =
181 + error !== null &&
182 + typeof error === 'object' &&
183 + error.message === 'test-error-do-not-surface';
184 +
185 + return !shouldIgnoreError;
186 + },
187 },
188 },
189 static: {