[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: {