mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 00:20:04 +01:00
[devtools] 1st class support of used Thenables (#32989)
Co-authored-by: Ruslan Lesiutin <rdlesyutin@gmail.com>
This commit is contained in:
parent
ad09027c16
commit
197d6a0403
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
72
packages/react-devtools-shared/src/hydration.js
vendored
72
packages/react-devtools-shared/src/hydration.js
vendored
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
41
packages/react-devtools-shared/src/utils.js
vendored
41
packages/react-devtools-shared/src/utils.js
vendored
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user