Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
72 changes: 71 additions & 1 deletion packages/react-devtools-shared/src/hydration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down
41 changes: 40 additions & 1 deletion packages/react-devtools-shared/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ export type DataType =
| 'nan'
| 'null'
| 'number'
| 'thenable'
| 'object'
| 'react_element'
| 'regexp'
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -963,7 +1002,7 @@ export function formatDataForPreview(
case 'nan':
case 'null':
case 'undefined':
return data;
return String(data);
default:
try {
return truncateForDisplay(String(data));
Expand Down
2 changes: 1 addition & 1 deletion packages/react-devtools-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions packages/react-devtools-shell/src/app/Hydration/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ const objectOfObjects = {
j: 9,
},
qux: {},
quux: {
k: undefined,
l: null,
},
};

function useOuterFoo() {
Expand Down Expand Up @@ -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>
Expand All @@ -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>
);
}
Expand Down
Loading
Loading