Skip to content

Commit 197d6a0

Browse files
eps1lonhoxyq
andauthored
[devtools] 1st class support of used Thenables (#32989)
Co-authored-by: Ruslan Lesiutin <[email protected]>
1 parent ad09027 commit 197d6a0

File tree

6 files changed

+306
-3
lines changed

6 files changed

+306
-3
lines changed

packages/react-devtools-shared/src/__tests__/inspectedElement-test.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,130 @@ describe('InspectedElement', () => {
815815
`);
816816
});
817817

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+
818942
it('should not consume iterables while inspecting', async () => {
819943
const Example = () => null;
820944

packages/react-devtools-shared/src/hydration.js

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export type Dehydrated = {
4343
type: string,
4444
};
4545

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.
4747
// These objects can't be serialized without losing type information,
4848
// so a "Unserializable" type wrapper is used (with meta-data keys) to send nested values-
4949
// while preserving the original type and name.
@@ -303,6 +303,76 @@ export function dehydrate(
303303
type,
304304
};
305305

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+
306376
case 'object':
307377
isPathAllowedCheck = isPathAllowed(path);
308378

packages/react-devtools-shared/src/utils.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,7 @@ export type DataType =
563563
| 'nan'
564564
| 'null'
565565
| 'number'
566+
| 'thenable'
566567
| 'object'
567568
| 'react_element'
568569
| 'regexp'
@@ -631,6 +632,8 @@ export function getDataType(data: Object): DataType {
631632
}
632633
} else if (data.constructor && data.constructor.name === 'RegExp') {
633634
return 'regexp';
635+
} else if (typeof data.then === 'function') {
636+
return 'thenable';
634637
} else {
635638
// $FlowFixMe[method-unbinding]
636639
const toStringValue = Object.prototype.toString.call(data);
@@ -934,6 +937,42 @@ export function formatDataForPreview(
934937
} catch (error) {
935938
return 'unserializable';
936939
}
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+
}
937976
case 'object':
938977
if (showFormattedValue) {
939978
const keys = Array.from(getAllEnumerableKeys(data)).sort(alphaSortKeys);
@@ -963,7 +1002,7 @@ export function formatDataForPreview(
9631002
case 'nan':
9641003
case 'null':
9651004
case 'undefined':
966-
return data;
1005+
return String(data);
9671006
default:
9681007
try {
9691008
return truncateForDisplay(String(data));

packages/react-devtools-shell/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Harness for testing local changes to the `react-devtools-inline` and `react-devt
22

33
## Development
44

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).
66

77
The test harness can then be run as follows:
88
```sh

packages/react-devtools-shell/src/app/Hydration/index.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ const objectOfObjects = {
4949
j: 9,
5050
},
5151
qux: {},
52+
quux: {
53+
k: undefined,
54+
l: null,
55+
},
5256
};
5357

5458
function useOuterFoo() {
@@ -106,6 +110,26 @@ function useInnerBaz() {
106110
return count;
107111
}
108112

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+
109133
export default function Hydration(): React.Node {
110134
return (
111135
<Fragment>
@@ -120,17 +144,55 @@ export default function Hydration(): React.Node {
120144
date={new Date()}
121145
array={arrayOfArrays}
122146
object={objectOfObjects}
147+
unusedPromise={unusedPromise}
148+
usedFulfilledPromise={usedFulfilledPromise}
149+
usedFulfilledRichPromise={usedFulfilledRichPromise}
150+
usedPendingPromise={usedPendingPromise}
151+
usedRejectedPromise={usedRejectedPromise}
123152
/>
124153
<DeepHooks />
125154
</Fragment>
126155
);
127156
}
128157

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+
129177
function DehydratableProps({array, object}: any) {
130178
return (
131179
<ul>
132180
<li>array: {JSON.stringify(array, null, 2)}</li>
133181
<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>
134196
</ul>
135197
);
136198
}

0 commit comments

Comments
 (0)