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

Co-authored-by: Ruslan Lesiutin <rdlesyutin@gmail.com>
This commit is contained in:
Sebastian "Sebbie" Silbermann 2025-04-24 13:46:31 +02:00 committed by GitHub
parent ad09027c16
commit 197d6a0403
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 306 additions and 3 deletions

View File

@ -815,6 +815,130 @@ describe('InspectedElement', () => {
`);
});
it('should support Thenables in React 19', async () => {
const Example = () => null;
class SubclassedPromise extends Promise {}
const plainThenable = {then() {}};
const subclassedPromise = new SubclassedPromise(() => {});
const unusedPromise = Promise.resolve();
const usedFulfilledPromise = Promise.resolve();
const usedFulfilledRichPromise = Promise.resolve({
some: {
deeply: {
nested: {
object: {
string: 'test',
fn: () => {},
},
},
},
},
});
const usedPendingPromise = new Promise(resolve => {});
const usedRejectedPromise = Promise.reject(
new Error('test-error-do-not-surface'),
);
function Use({value}) {
React.use(value);
}
await utils.actAsync(() =>
render(
<>
<Example
plainThenable={plainThenable}
subclassedPromise={subclassedPromise}
unusedPromise={unusedPromise}
usedFulfilledPromise={usedFulfilledPromise}
usedFulfilledRichPromise={usedFulfilledRichPromise}
usedPendingPromise={usedPendingPromise}
usedRejectedPromise={usedRejectedPromise}
/>
<React.Suspense>
<Use value={usedPendingPromise} />
</React.Suspense>
<React.Suspense>
<Use value={usedFulfilledPromise} />
</React.Suspense>
<React.Suspense>
<Use value={usedFulfilledRichPromise} />
</React.Suspense>
<ErrorBoundary>
<React.Suspense>
<Use value={usedRejectedPromise} />
</React.Suspense>
</ErrorBoundary>
</>,
),
);
const inspectedElement = await inspectElementAtIndex(0);
expect(inspectedElement.props).toMatchInlineSnapshot(`
{
"plainThenable": Dehydrated {
"preview_short": Thenable,
"preview_long": Thenable,
},
"subclassedPromise": Dehydrated {
"preview_short": SubclassedPromise,
"preview_long": SubclassedPromise,
},
"unusedPromise": Dehydrated {
"preview_short": Promise,
"preview_long": Promise,
},
"usedFulfilledPromise": {
"value": undefined,
},
"usedFulfilledRichPromise": {
"value": Dehydrated {
"preview_short": {},
"preview_long": {some: {}},
},
},
"usedPendingPromise": Dehydrated {
"preview_short": pending Promise,
"preview_long": pending Promise,
},
"usedRejectedPromise": {
"reason": Dehydrated {
"preview_short": Error,
"preview_long": Error,
},
},
}
`);
});
it('should support Promises in React 18', async () => {
const Example = () => null;
const unusedPromise = Promise.resolve();
await utils.actAsync(() =>
render(
<>
<Example unusedPromise={unusedPromise} />
</>,
),
);
const inspectedElement = await inspectElementAtIndex(0);
expect(inspectedElement.props).toMatchInlineSnapshot(`
{
"unusedPromise": Dehydrated {
"preview_short": Promise,
"preview_long": Promise,
},
}
`);
});
it('should not consume iterables while inspecting', async () => {
const Example = () => null;

View File

@ -43,7 +43,7 @@ export type Dehydrated = {
type: string,
};
// Typed arrays and other complex iteratable objects (e.g. Map, Set, ImmutableJS) need special handling.
// Typed arrays, other complex iteratable objects (e.g. Map, Set, ImmutableJS) or Promises need special handling.
// These objects can't be serialized without losing type information,
// so a "Unserializable" type wrapper is used (with meta-data keys) to send nested values-
// while preserving the original type and name.
@ -303,6 +303,76 @@ export function dehydrate(
type,
};
case 'thenable':
isPathAllowedCheck = isPathAllowed(path);
if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
return {
inspectable:
data.status === 'fulfilled' || data.status === 'rejected',
preview_short: formatDataForPreview(data, false),
preview_long: formatDataForPreview(data, true),
name: data.toString(),
type,
};
}
switch (data.status) {
case 'fulfilled': {
const unserializableValue: Unserializable = {
unserializable: true,
type: type,
preview_short: formatDataForPreview(data, false),
preview_long: formatDataForPreview(data, true),
name: 'fulfilled Thenable',
};
unserializableValue.value = dehydrate(
data.value,
cleaned,
unserializable,
path.concat(['value']),
isPathAllowed,
isPathAllowedCheck ? 1 : level + 1,
);
unserializable.push(path);
return unserializableValue;
}
case 'rejected': {
const unserializableValue: Unserializable = {
unserializable: true,
type: type,
preview_short: formatDataForPreview(data, false),
preview_long: formatDataForPreview(data, true),
name: 'rejected Thenable',
};
unserializableValue.reason = dehydrate(
data.reason,
cleaned,
unserializable,
path.concat(['reason']),
isPathAllowed,
isPathAllowedCheck ? 1 : level + 1,
);
unserializable.push(path);
return unserializableValue;
}
default:
cleaned.push(path);
return {
inspectable: false,
preview_short: formatDataForPreview(data, false),
preview_long: formatDataForPreview(data, true),
name: data.toString(),
type,
};
}
case 'object':
isPathAllowedCheck = isPathAllowed(path);

View File

@ -563,6 +563,7 @@ export type DataType =
| 'nan'
| 'null'
| 'number'
| 'thenable'
| 'object'
| 'react_element'
| 'regexp'
@ -631,6 +632,8 @@ export function getDataType(data: Object): DataType {
}
} else if (data.constructor && data.constructor.name === 'RegExp') {
return 'regexp';
} else if (typeof data.then === 'function') {
return 'thenable';
} else {
// $FlowFixMe[method-unbinding]
const toStringValue = Object.prototype.toString.call(data);
@ -934,6 +937,42 @@ export function formatDataForPreview(
} catch (error) {
return 'unserializable';
}
case 'thenable':
let displayName: string;
if (isPlainObject(data)) {
displayName = 'Thenable';
} else {
let resolvedConstructorName = data.constructor.name;
if (typeof resolvedConstructorName !== 'string') {
resolvedConstructorName =
Object.getPrototypeOf(data).constructor.name;
}
if (typeof resolvedConstructorName === 'string') {
displayName = resolvedConstructorName;
} else {
displayName = 'Thenable';
}
}
switch (data.status) {
case 'pending':
return `pending ${displayName}`;
case 'fulfilled':
if (showFormattedValue) {
const formatted = formatDataForPreview(data.value, false);
return `fulfilled ${displayName} {${truncateForDisplay(formatted)}}`;
} else {
return `fulfilled ${displayName} {…}`;
}
case 'rejected':
if (showFormattedValue) {
const formatted = formatDataForPreview(data.reason, false);
return `rejected ${displayName} {${truncateForDisplay(formatted)}}`;
} else {
return `rejected ${displayName} {…}`;
}
default:
return displayName;
}
case 'object':
if (showFormattedValue) {
const keys = Array.from(getAllEnumerableKeys(data)).sort(alphaSortKeys);
@ -963,7 +1002,7 @@ export function formatDataForPreview(
case 'nan':
case 'null':
case 'undefined':
return data;
return String(data);
default:
try {
return truncateForDisplay(String(data));

View File

@ -2,7 +2,7 @@ Harness for testing local changes to the `react-devtools-inline` and `react-devt
## Development
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).
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).
The test harness can then be run as follows:
```sh

View File

@ -49,6 +49,10 @@ const objectOfObjects = {
j: 9,
},
qux: {},
quux: {
k: undefined,
l: null,
},
};
function useOuterFoo() {
@ -106,6 +110,26 @@ function useInnerBaz() {
return count;
}
const unusedPromise = Promise.resolve();
const usedFulfilledPromise = Promise.resolve();
const usedFulfilledRichPromise = Promise.resolve({
some: {
deeply: {
nested: {
object: {
string: 'test',
fn: () => {},
},
},
},
},
});
const usedPendingPromise = new Promise(resolve => {});
const usedRejectedPromise = Promise.reject(
// eslint-disable-next-line react-internal/prod-error-codes
new Error('test-error-do-not-surface'),
);
export default function Hydration(): React.Node {
return (
<Fragment>
@ -120,17 +144,55 @@ export default function Hydration(): React.Node {
date={new Date()}
array={arrayOfArrays}
object={objectOfObjects}
unusedPromise={unusedPromise}
usedFulfilledPromise={usedFulfilledPromise}
usedFulfilledRichPromise={usedFulfilledRichPromise}
usedPendingPromise={usedPendingPromise}
usedRejectedPromise={usedRejectedPromise}
/>
<DeepHooks />
</Fragment>
);
}
function Use({value}: {value: Promise<mixed>}): React.Node {
React.use(value);
return null;
}
class IgnoreErrors extends React.Component {
state: {hasError: boolean} = {hasError: false};
static getDerivedStateFromError(): {hasError: boolean} {
return {hasError: true};
}
render(): React.Node {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
function DehydratableProps({array, object}: any) {
return (
<ul>
<li>array: {JSON.stringify(array, null, 2)}</li>
<li>object: {JSON.stringify(object, null, 2)}</li>
<React.Suspense>
<Use value={usedPendingPromise} />
</React.Suspense>
<React.Suspense>
<Use value={usedFulfilledPromise} />
</React.Suspense>
<React.Suspense>
<Use value={usedFulfilledRichPromise} />
</React.Suspense>
<IgnoreErrors>
<React.Suspense>
<Use value={usedRejectedPromise} />
</React.Suspense>
</IgnoreErrors>
</ul>
);
}

View File

@ -176,6 +176,14 @@ const appServer = new WebpackDevServer(
logging: 'warn',
overlay: {
warnings: false,
runtimeErrors: error => {
const shouldIgnoreError =
error !== null &&
typeof error === 'object' &&
error.message === 'test-error-do-not-surface';
return !shouldIgnoreError;
},
},
},
static: {