diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
index 3374224cca0fc..cc925dad312b0 100644
--- a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+++ b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
@@ -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(
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >,
+ ),
+ );
+
+ 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(
+ <>
+
+ >,
+ ),
+ );
+
+ 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;
diff --git a/packages/react-devtools-shared/src/hydration.js b/packages/react-devtools-shared/src/hydration.js
index c21efe40a88fa..3df47c68bd7d6 100644
--- a/packages/react-devtools-shared/src/hydration.js
+++ b/packages/react-devtools-shared/src/hydration.js
@@ -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);
diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js
index b0a8f5c53e0cd..ebb6265b1d668 100644
--- a/packages/react-devtools-shared/src/utils.js
+++ b/packages/react-devtools-shared/src/utils.js
@@ -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));
diff --git a/packages/react-devtools-shell/README.md b/packages/react-devtools-shell/README.md
index da572de45364e..0a78ebc86b1d8 100644
--- a/packages/react-devtools-shell/README.md
+++ b/packages/react-devtools-shell/README.md
@@ -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
diff --git a/packages/react-devtools-shell/src/app/Hydration/index.js b/packages/react-devtools-shell/src/app/Hydration/index.js
index 29c40c4aee2d2..227f8558ef42b 100644
--- a/packages/react-devtools-shell/src/app/Hydration/index.js
+++ b/packages/react-devtools-shell/src/app/Hydration/index.js
@@ -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 (
@@ -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}
/>
);
}
+function Use({value}: {value: Promise}): 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 (
- array: {JSON.stringify(array, null, 2)}
- object: {JSON.stringify(object, null, 2)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/react-devtools-shell/webpack-server.js b/packages/react-devtools-shell/webpack-server.js
index cd35ff3ed9a89..a665601b66010 100644
--- a/packages/react-devtools-shell/webpack-server.js
+++ b/packages/react-devtools-shell/webpack-server.js
@@ -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: {