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