Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877c1bcbd7 | ||
|
|
454fc41fc7 | ||
|
|
f93b9fd44b | ||
|
|
b731fe28cc | ||
|
|
88ee1f5955 | ||
|
|
bcf97c7564 | ||
|
|
ba5b843692 |
@@ -635,6 +635,7 @@ module.exports = {
|
||||
FocusOptions: 'readonly',
|
||||
OptionalEffectTiming: 'readonly',
|
||||
|
||||
__REACT_ROOT_PATH_TEST__: 'readonly',
|
||||
spyOnDev: 'readonly',
|
||||
spyOnDevAndProd: 'readonly',
|
||||
spyOnProd: 'readonly',
|
||||
|
||||
@@ -225,8 +225,15 @@ export const EnvironmentConfigSchema = z.object({
|
||||
|
||||
/**
|
||||
* Validate that dependencies supplied to effect hooks are exhaustive.
|
||||
* Can be:
|
||||
* - 'off': No validation (default)
|
||||
* - 'all': Validate and report both missing and extra dependencies
|
||||
* - 'missing-only': Only report missing dependencies
|
||||
* - 'extra-only': Only report extra/unnecessary dependencies
|
||||
*/
|
||||
validateExhaustiveEffectDependencies: z.boolean().default(false),
|
||||
validateExhaustiveEffectDependencies: z
|
||||
.enum(['off', 'all', 'missing-only', 'extra-only'])
|
||||
.default('off'),
|
||||
|
||||
/**
|
||||
* When this is true, rather than pruning existing manual memoization but ensuring or validating
|
||||
|
||||
@@ -141,6 +141,7 @@ export function validateExhaustiveDependencies(
|
||||
reactive,
|
||||
startMemo.depsLoc,
|
||||
ErrorCategory.MemoDependencies,
|
||||
'all',
|
||||
);
|
||||
if (diagnostic != null) {
|
||||
error.pushDiagnostic(diagnostic);
|
||||
@@ -159,7 +160,7 @@ export function validateExhaustiveDependencies(
|
||||
onStartMemoize,
|
||||
onFinishMemoize,
|
||||
onEffect: (inferred, manual, manualMemoLoc) => {
|
||||
if (env.config.validateExhaustiveEffectDependencies === false) {
|
||||
if (env.config.validateExhaustiveEffectDependencies === 'off') {
|
||||
return;
|
||||
}
|
||||
if (DEBUG) {
|
||||
@@ -195,12 +196,17 @@ export function validateExhaustiveDependencies(
|
||||
});
|
||||
}
|
||||
}
|
||||
const effectReportMode =
|
||||
typeof env.config.validateExhaustiveEffectDependencies === 'string'
|
||||
? env.config.validateExhaustiveEffectDependencies
|
||||
: 'all';
|
||||
const diagnostic = validateDependencies(
|
||||
Array.from(inferred),
|
||||
manualDeps,
|
||||
reactive,
|
||||
manualMemoLoc,
|
||||
ErrorCategory.EffectExhaustiveDependencies,
|
||||
effectReportMode,
|
||||
);
|
||||
if (diagnostic != null) {
|
||||
error.pushDiagnostic(diagnostic);
|
||||
@@ -220,6 +226,7 @@ function validateDependencies(
|
||||
category:
|
||||
| ErrorCategory.MemoDependencies
|
||||
| ErrorCategory.EffectExhaustiveDependencies,
|
||||
exhaustiveDepsReportMode: 'all' | 'missing-only' | 'extra-only',
|
||||
): CompilerDiagnostic | null {
|
||||
// Sort dependencies by name and path, with shorter/non-optional paths first
|
||||
inferred.sort((a, b) => {
|
||||
@@ -370,9 +377,20 @@ function validateDependencies(
|
||||
extra.push(dep);
|
||||
}
|
||||
|
||||
if (missing.length !== 0 || extra.length !== 0) {
|
||||
// Filter based on report mode
|
||||
const filteredMissing =
|
||||
exhaustiveDepsReportMode === 'extra-only' ? [] : missing;
|
||||
const filteredExtra =
|
||||
exhaustiveDepsReportMode === 'missing-only' ? [] : extra;
|
||||
|
||||
if (filteredMissing.length !== 0 || filteredExtra.length !== 0) {
|
||||
let suggestion: CompilerSuggestion | null = null;
|
||||
if (manualMemoLoc != null && typeof manualMemoLoc !== 'symbol') {
|
||||
if (
|
||||
manualMemoLoc != null &&
|
||||
typeof manualMemoLoc !== 'symbol' &&
|
||||
manualMemoLoc.start.index != null &&
|
||||
manualMemoLoc.end.index != null
|
||||
) {
|
||||
suggestion = {
|
||||
description: 'Update dependencies',
|
||||
range: [manualMemoLoc.start.index, manualMemoLoc.end.index],
|
||||
@@ -388,8 +406,13 @@ function validateDependencies(
|
||||
.join(', ')}]`,
|
||||
};
|
||||
}
|
||||
const diagnostic = createDiagnostic(category, missing, extra, suggestion);
|
||||
for (const dep of missing) {
|
||||
const diagnostic = createDiagnostic(
|
||||
category,
|
||||
filteredMissing,
|
||||
filteredExtra,
|
||||
suggestion,
|
||||
);
|
||||
for (const dep of filteredMissing) {
|
||||
let reactiveStableValueHint = '';
|
||||
if (isStableType(dep.identifier)) {
|
||||
reactiveStableValueHint =
|
||||
@@ -402,7 +425,7 @@ function validateDependencies(
|
||||
loc: dep.loc,
|
||||
});
|
||||
}
|
||||
for (const dep of extra) {
|
||||
for (const dep of filteredExtra) {
|
||||
if (dep.root.kind === 'Global') {
|
||||
diagnostic.withDetails({
|
||||
kind: 'error',
|
||||
|
||||
@@ -511,13 +511,6 @@ type TreeNode = {
|
||||
children: Array<TreeNode>;
|
||||
};
|
||||
|
||||
type DataFlowInfo = {
|
||||
rootSources: string;
|
||||
trees: Array<string>;
|
||||
propsArr: Array<string>;
|
||||
stateArr: Array<string>;
|
||||
};
|
||||
|
||||
function buildTreeNode(
|
||||
sourceId: IdentifierId,
|
||||
context: ValidationContext,
|
||||
@@ -635,51 +628,6 @@ function renderTree(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the data flow information including trees and source categorization
|
||||
*/
|
||||
function buildDataFlowInfo(
|
||||
sourceIds: Set<IdentifierId>,
|
||||
context: ValidationContext,
|
||||
): DataFlowInfo {
|
||||
const propsSet = new Set<string>();
|
||||
const stateSet = new Set<string>();
|
||||
|
||||
const rootNodesMap = new Map<string, TreeNode>();
|
||||
for (const id of sourceIds) {
|
||||
const nodes = buildTreeNode(id, context);
|
||||
for (const node of nodes) {
|
||||
if (!rootNodesMap.has(node.name)) {
|
||||
rootNodesMap.set(node.name, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rootNodes = Array.from(rootNodesMap.values());
|
||||
|
||||
const trees = rootNodes.map((node, index) =>
|
||||
renderTree(node, '', index === rootNodes.length - 1, propsSet, stateSet),
|
||||
);
|
||||
|
||||
const propsArr = Array.from(propsSet);
|
||||
const stateArr = Array.from(stateSet);
|
||||
|
||||
let rootSources = '';
|
||||
if (propsArr.length > 0) {
|
||||
rootSources += `Props: [${propsArr.join(', ')}]`;
|
||||
}
|
||||
if (stateArr.length > 0) {
|
||||
if (rootSources) rootSources += '\n';
|
||||
rootSources += `State: [${stateArr.join(', ')}]`;
|
||||
}
|
||||
|
||||
return {
|
||||
rootSources,
|
||||
trees,
|
||||
propsArr,
|
||||
stateArr,
|
||||
};
|
||||
}
|
||||
|
||||
function getFnLocalDeps(
|
||||
fn: FunctionExpression | undefined,
|
||||
): Set<IdentifierId> | undefined {
|
||||
@@ -844,16 +792,47 @@ function validateEffect(
|
||||
effectSetStateUsages.get(rootSetStateCall)!.size ===
|
||||
context.setStateUsages.get(rootSetStateCall)!.size - 1
|
||||
) {
|
||||
const propsSet = new Set<string>();
|
||||
const stateSet = new Set<string>();
|
||||
|
||||
const rootNodesMap = new Map<string, TreeNode>();
|
||||
for (const id of derivedSetStateCall.sourceIds) {
|
||||
const nodes = buildTreeNode(id, context);
|
||||
for (const node of nodes) {
|
||||
if (!rootNodesMap.has(node.name)) {
|
||||
rootNodesMap.set(node.name, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rootNodes = Array.from(rootNodesMap.values());
|
||||
|
||||
const trees = rootNodes.map((node, index) =>
|
||||
renderTree(
|
||||
node,
|
||||
'',
|
||||
index === rootNodes.length - 1,
|
||||
propsSet,
|
||||
stateSet,
|
||||
),
|
||||
);
|
||||
|
||||
for (const dep of derivedSetStateCall.sourceIds) {
|
||||
if (cleanUpFunctionDeps !== undefined && cleanUpFunctionDeps.has(dep)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const {rootSources, trees} = buildDataFlowInfo(
|
||||
derivedSetStateCall.sourceIds,
|
||||
context,
|
||||
);
|
||||
const propsArr = Array.from(propsSet);
|
||||
const stateArr = Array.from(stateSet);
|
||||
|
||||
let rootSources = '';
|
||||
if (propsArr.length > 0) {
|
||||
rootSources += `Props: [${propsArr.join(', ')}]`;
|
||||
}
|
||||
if (stateArr.length > 0) {
|
||||
if (rootSources) rootSources += '\n';
|
||||
rootSources += `State: [${stateArr.join(', ')}]`;
|
||||
}
|
||||
|
||||
const description = `Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
|
||||
|
||||
@@ -878,80 +857,6 @@ See: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-o
|
||||
message: 'This should be computed during render, not in an effect',
|
||||
}),
|
||||
);
|
||||
} else if (
|
||||
rootSetStateCall !== null &&
|
||||
effectSetStateUsages.has(rootSetStateCall) &&
|
||||
context.setStateUsages.has(rootSetStateCall) &&
|
||||
effectSetStateUsages.get(rootSetStateCall)!.size <
|
||||
context.setStateUsages.get(rootSetStateCall)!.size
|
||||
) {
|
||||
for (const dep of derivedSetStateCall.sourceIds) {
|
||||
if (cleanUpFunctionDeps !== undefined && cleanUpFunctionDeps.has(dep)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const {rootSources, trees} = buildDataFlowInfo(
|
||||
derivedSetStateCall.sourceIds,
|
||||
context,
|
||||
);
|
||||
|
||||
// Find setState calls outside the effect
|
||||
const allSetStateUsages = context.setStateUsages.get(rootSetStateCall)!;
|
||||
const effectUsages = effectSetStateUsages.get(rootSetStateCall)!;
|
||||
const outsideEffectUsages: Array<SourceLocation> = [];
|
||||
|
||||
for (const usage of allSetStateUsages) {
|
||||
if (!effectUsages.has(usage)) {
|
||||
outsideEffectUsages.push(usage);
|
||||
}
|
||||
}
|
||||
|
||||
const description = `Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user
|
||||
|
||||
This setState call is setting a derived value that depends on the following reactive sources:
|
||||
|
||||
${rootSources}
|
||||
|
||||
Data Flow Tree:
|
||||
${trees.join('\n')}
|
||||
|
||||
This state is also being set outside of the effect. Consider hoisting the state up to a parent component and making this a controlled component.
|
||||
|
||||
See: https://react.dev/learn/sharing-state-between-components`;
|
||||
|
||||
const diagnosticDetails: Array<{
|
||||
kind: 'error';
|
||||
loc: SourceLocation;
|
||||
message: string;
|
||||
}> = [
|
||||
{
|
||||
kind: 'error',
|
||||
loc: derivedSetStateCall.value.callee.loc,
|
||||
message: 'setState in effect',
|
||||
},
|
||||
];
|
||||
|
||||
for (const usage of outsideEffectUsages) {
|
||||
diagnosticDetails.push({
|
||||
kind: 'error',
|
||||
loc: usage,
|
||||
message: 'setState outside effect',
|
||||
});
|
||||
}
|
||||
|
||||
let diagnostic = CompilerDiagnostic.create({
|
||||
description: description,
|
||||
category: ErrorCategory.EffectDerivationsOfState,
|
||||
reason:
|
||||
'Consider hoisting state to parent and making this a controlled component',
|
||||
});
|
||||
|
||||
for (const detail of diagnosticDetails) {
|
||||
diagnostic = diagnostic.withDetails(detail);
|
||||
}
|
||||
|
||||
context.errors.pushDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect, useEffectEvent} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect, useEffectEvent} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateExhaustiveEffectDependencies:"extra-only"
|
||||
import {useEffect} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
// no error: missing dep not reported in extra-only mode
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, []);
|
||||
|
||||
// error: extra dep - y
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, [x, y]);
|
||||
|
||||
// error: extra dep - y (missing dep - z not reported)
|
||||
useEffect(() => {
|
||||
log(x, z);
|
||||
}, [x, y]);
|
||||
|
||||
// error: extra dep - x.y
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, [x.y]);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 3 errors:
|
||||
|
||||
Error: Found extra effect dependencies
|
||||
|
||||
Extra dependencies can cause an effect to fire more often than it should, resulting in performance problems such as excessive renders and side effects.
|
||||
|
||||
error.invalid-exhaustive-effect-deps-extra-only.ts:13:9
|
||||
11 | useEffect(() => {
|
||||
12 | log(x);
|
||||
> 13 | }, [x, y]);
|
||||
| ^ Unnecessary dependency `y`
|
||||
14 |
|
||||
15 | // error: extra dep - y (missing dep - z not reported)
|
||||
16 | useEffect(() => {
|
||||
|
||||
Inferred dependencies: `[x]`
|
||||
|
||||
Error: Found extra effect dependencies
|
||||
|
||||
Extra dependencies can cause an effect to fire more often than it should, resulting in performance problems such as excessive renders and side effects.
|
||||
|
||||
error.invalid-exhaustive-effect-deps-extra-only.ts:18:9
|
||||
16 | useEffect(() => {
|
||||
17 | log(x, z);
|
||||
> 18 | }, [x, y]);
|
||||
| ^ Unnecessary dependency `y`
|
||||
19 |
|
||||
20 | // error: extra dep - x.y
|
||||
21 | useEffect(() => {
|
||||
|
||||
Inferred dependencies: `[x, z]`
|
||||
|
||||
Error: Found extra effect dependencies
|
||||
|
||||
Extra dependencies can cause an effect to fire more often than it should, resulting in performance problems such as excessive renders and side effects.
|
||||
|
||||
error.invalid-exhaustive-effect-deps-extra-only.ts:23:6
|
||||
21 | useEffect(() => {
|
||||
22 | log(x);
|
||||
> 23 | }, [x.y]);
|
||||
| ^^^ Overly precise dependency `x.y`, use `x` instead
|
||||
24 | }
|
||||
25 |
|
||||
|
||||
Inferred dependencies: `[x]`
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @validateExhaustiveEffectDependencies:"extra-only"
|
||||
import {useEffect} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
// no error: missing dep not reported in extra-only mode
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, []);
|
||||
|
||||
// error: extra dep - y
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, [x, y]);
|
||||
|
||||
// error: extra dep - y (missing dep - z not reported)
|
||||
useEffect(() => {
|
||||
log(x, z);
|
||||
}, [x, y]);
|
||||
|
||||
// error: extra dep - x.y
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, [x.y]);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateExhaustiveEffectDependencies:"missing-only"
|
||||
import {useEffect} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
// error: missing dep - x
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, []);
|
||||
|
||||
// no error: extra dep not reported in missing-only mode
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, [x, y]);
|
||||
|
||||
// error: missing dep - z (extra dep - y not reported)
|
||||
useEffect(() => {
|
||||
log(x, z);
|
||||
}, [x, y]);
|
||||
|
||||
// error: missing dep x
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, [x.y]);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 3 errors:
|
||||
|
||||
Error: Found missing effect dependencies
|
||||
|
||||
Missing dependencies can cause an effect to fire less often than it should.
|
||||
|
||||
error.invalid-exhaustive-effect-deps-missing-only.ts:7:8
|
||||
5 | // error: missing dep - x
|
||||
6 | useEffect(() => {
|
||||
> 7 | log(x);
|
||||
| ^ Missing dependency `x`
|
||||
8 | }, []);
|
||||
9 |
|
||||
10 | // no error: extra dep not reported in missing-only mode
|
||||
|
||||
Inferred dependencies: `[x]`
|
||||
|
||||
Error: Found missing effect dependencies
|
||||
|
||||
Missing dependencies can cause an effect to fire less often than it should.
|
||||
|
||||
error.invalid-exhaustive-effect-deps-missing-only.ts:17:11
|
||||
15 | // error: missing dep - z (extra dep - y not reported)
|
||||
16 | useEffect(() => {
|
||||
> 17 | log(x, z);
|
||||
| ^ Missing dependency `z`
|
||||
18 | }, [x, y]);
|
||||
19 |
|
||||
20 | // error: missing dep x
|
||||
|
||||
Inferred dependencies: `[x, z]`
|
||||
|
||||
Error: Found missing effect dependencies
|
||||
|
||||
Missing dependencies can cause an effect to fire less often than it should.
|
||||
|
||||
error.invalid-exhaustive-effect-deps-missing-only.ts:22:8
|
||||
20 | // error: missing dep x
|
||||
21 | useEffect(() => {
|
||||
> 22 | log(x);
|
||||
| ^ Missing dependency `x`
|
||||
23 | }, [x.y]);
|
||||
24 | }
|
||||
25 |
|
||||
|
||||
Inferred dependencies: `[x]`
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @validateExhaustiveEffectDependencies:"missing-only"
|
||||
import {useEffect} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
// error: missing dep - x
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, []);
|
||||
|
||||
// no error: extra dep not reported in missing-only mode
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, [x, y]);
|
||||
|
||||
// error: missing dep - z (extra dep - y not reported)
|
||||
useEffect(() => {
|
||||
log(x, z);
|
||||
}, [x, y]);
|
||||
|
||||
// error: missing dep x
|
||||
useEffect(() => {
|
||||
log(x);
|
||||
}, [x.y]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies:"all"
|
||||
import {
|
||||
useCallback,
|
||||
useTransition,
|
||||
@@ -69,7 +69,7 @@ export const FIXTURE_ENTRYPOINT = {
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies
|
||||
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies:"all"
|
||||
import {
|
||||
useCallback,
|
||||
useTransition,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies:"all"
|
||||
import {
|
||||
useCallback,
|
||||
useTransition,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect, useEffectEvent} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
@@ -30,7 +30,7 @@ function Component({x, y, z}) {
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveEffectDependencies
|
||||
import { c as _c } from "react/compiler-runtime"; // @validateExhaustiveEffectDependencies:"all"
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
|
||||
function Component(t0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateExhaustiveEffectDependencies
|
||||
// @validateExhaustiveEffectDependencies:"all"
|
||||
import {useEffect, useEffectEvent} from 'react';
|
||||
|
||||
function Component({x, y, z}) {
|
||||
|
||||
@@ -167,3 +167,16 @@ function InvalidUseMemo({items}) {
|
||||
const sorted = useMemo(() => [...items].sort(), []);
|
||||
return <div>{sorted.length}</div>;
|
||||
}
|
||||
|
||||
// Invalid: missing/extra deps in useEffect
|
||||
function InvalidEffectDeps({a, b}) {
|
||||
useEffect(() => {
|
||||
console.log(a);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(a);
|
||||
// TODO: eslint-disable-next-line react-hooks/exhaustive-effect-dependencies
|
||||
}, [a, b]);
|
||||
}
|
||||
|
||||
@@ -603,6 +603,18 @@ has-flag@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
version "0.25.1"
|
||||
resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480"
|
||||
integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==
|
||||
|
||||
hermes-parser@^0.25.1:
|
||||
version "0.25.1"
|
||||
resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1"
|
||||
integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==
|
||||
dependencies:
|
||||
hermes-estree "0.25.1"
|
||||
|
||||
ignore@^5.2.0:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||
@@ -877,12 +889,12 @@ yocto-queue@^0.1.0:
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
"zod-validation-error@^3.0.3 || ^4.0.0":
|
||||
"zod-validation-error@^3.5.0 || ^4.0.0":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918"
|
||||
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
|
||||
|
||||
"zod@^3.22.4 || ^4.0.0":
|
||||
version "4.1.11"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5"
|
||||
integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==
|
||||
"zod@^3.25.0 || ^4.0.0":
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.2.0.tgz#01e86f2c2b6d525a1b9fa6dbe78beccad082118f"
|
||||
integrity sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==
|
||||
|
||||
@@ -42,6 +42,7 @@ const COMPILER_OPTIONS: PluginOptions = {
|
||||
// Temporarily enabled for internal testing
|
||||
enableUseKeyedState: true,
|
||||
enableVerboseNoSetStateInEffect: true,
|
||||
validateExhaustiveEffectDependencies: 'extra-only',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '../../');
|
||||
|
||||
type DebugInfoConfig = {
|
||||
ignoreProps?: boolean,
|
||||
ignoreRscStreamInfo?: boolean,
|
||||
@@ -34,7 +30,7 @@ function normalizeStack(stack) {
|
||||
const [name, file, line, col, enclosingLine, enclosingCol] = stack[i];
|
||||
copy.push([
|
||||
name,
|
||||
file.replace(repoRoot, ''),
|
||||
file.replace(__REACT_ROOT_PATH_TEST__, ''),
|
||||
line,
|
||||
col,
|
||||
enclosingLine,
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
if (typeof Blob === 'undefined') {
|
||||
global.Blob = require('buffer').Blob;
|
||||
}
|
||||
@@ -33,9 +31,8 @@ function normalizeCodeLocInfo(str) {
|
||||
);
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '../../../../');
|
||||
function normalizeReactCodeLocInfo(str) {
|
||||
const repoRootForRegexp = repoRoot.replace(/\//g, '\\/');
|
||||
const repoRootForRegexp = __REACT_ROOT_PATH_TEST__.replace(/\//g, '\\/');
|
||||
const repoFileLocMatch = new RegExp(`${repoRootForRegexp}.+?:\\d+:\\d+`, 'g');
|
||||
return str && str.replace(repoFileLocMatch, '**');
|
||||
}
|
||||
@@ -727,6 +724,25 @@ describe('ReactFlight', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('can transport cyclic arrays', async () => {
|
||||
function ComponentClient({prop, obj}) {
|
||||
expect(prop[1]).toBe(prop);
|
||||
expect(prop[0]).toBe(obj);
|
||||
}
|
||||
const Component = clientReference(ComponentClient);
|
||||
|
||||
const obj = {};
|
||||
const cyclic = [obj];
|
||||
cyclic[1] = cyclic;
|
||||
const model = <Component prop={cyclic} obj={obj} />;
|
||||
|
||||
const transport = ReactNoopFlightServer.render(model);
|
||||
|
||||
await act(async () => {
|
||||
ReactNoop.render(await ReactNoopFlightClient.read(transport));
|
||||
});
|
||||
});
|
||||
|
||||
it('can render a lazy component as a shared component on the server', async () => {
|
||||
function SharedComponent({text}) {
|
||||
return (
|
||||
|
||||
@@ -32,7 +32,7 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
#### `Settings`
|
||||
| Spec | Default value |
|
||||
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <pre>{<br> appendComponentStack: boolean,<br> breakOnConsoleErrors: boolean,<br> showInlineWarningsAndErrors: boolean,<br> hideConsoleLogsInStrictMode: boolean<br>}</pre> | <pre>{<br> appendComponentStack: true,<br> breakOnConsoleErrors: false,<br> showInlineWarningsAndErrors: true,<br> hideConsoleLogsInStrictMode: false<br>}</pre> |
|
||||
| <pre>{<br> appendComponentStack: boolean,<br> breakOnConsoleErrors: boolean,<br> showInlineWarningsAndErrors: boolean,<br> hideConsoleLogsInStrictMode: boolean,<br> disableSecondConsoleLogDimmingInStrictMode: boolean<br>}</pre> | <pre>{<br> appendComponentStack: true,<br> breakOnConsoleErrors: false,<br> showInlineWarningsAndErrors: true,<br> hideConsoleLogsInStrictMode: false,<br> disableSecondConsoleLogDimmingInStrictMode: false<br>}</pre> |
|
||||
|
||||
### `connectToDevTools` options
|
||||
| Prop | Default | Description |
|
||||
@@ -53,7 +53,7 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
| `onSubscribe` | Function, which receives listener (function, with a single argument) as an argument. Called when backend subscribes to messages from the other end (frontend). |
|
||||
| `onUnsubscribe` | Function, which receives listener (function) as an argument. Called when backend unsubscribes to messages from the other end (frontend). |
|
||||
| `onMessage` | Function, which receives 2 arguments: event (string) and payload (any). Called when backend emits a message, which should be sent to the frontend. |
|
||||
| `onSettingsUpdated` | A callback that will be called when the user updates the settings in the UI. You can use it for persisting user settings. |
|
||||
| `onSettingsUpdated` | A callback that will be called when the user updates the settings in the UI. You can use it for persisting user settings. |
|
||||
|
||||
Unlike `connectToDevTools`, `connectWithCustomMessagingProtocol` returns a callback, which can be used for unsubscribing the backend from the global DevTools hook.
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ async function messageListener(event: MessageEvent) {
|
||||
if (typeof settings.hideConsoleLogsInStrictMode !== 'boolean') {
|
||||
settings.hideConsoleLogsInStrictMode = false;
|
||||
}
|
||||
if (
|
||||
typeof settings.disableSecondConsoleLogDimmingInStrictMode !== 'boolean'
|
||||
) {
|
||||
settings.disableSecondConsoleLogDimmingInStrictMode = false;
|
||||
}
|
||||
|
||||
window.postMessage({
|
||||
source: 'react-devtools-hook-settings-injector',
|
||||
|
||||
@@ -27,6 +27,7 @@ function startActivation(contentWindow: any, bridge: BackendBridge) {
|
||||
componentFilters,
|
||||
showInlineWarningsAndErrors,
|
||||
hideConsoleLogsInStrictMode,
|
||||
disableSecondConsoleLogDimmingInStrictMode,
|
||||
} = data;
|
||||
|
||||
contentWindow.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ =
|
||||
@@ -38,6 +39,8 @@ function startActivation(contentWindow: any, bridge: BackendBridge) {
|
||||
showInlineWarningsAndErrors;
|
||||
contentWindow.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ =
|
||||
hideConsoleLogsInStrictMode;
|
||||
contentWindow.__REACT_DEVTOOLS_DISABLE_SECOND_CONSOLE_LOG_DIMMING_IN_STRICT_MODE__ =
|
||||
disableSecondConsoleLogDimmingInStrictMode;
|
||||
|
||||
// TRICKY
|
||||
// The backend entry point may be required in the context of an iframe or the parent window.
|
||||
@@ -53,6 +56,8 @@ function startActivation(contentWindow: any, bridge: BackendBridge) {
|
||||
showInlineWarningsAndErrors;
|
||||
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ =
|
||||
hideConsoleLogsInStrictMode;
|
||||
window.__REACT_DEVTOOLS_DISABLE_SECOND_CONSOLE_LOG_DIMMING_IN_STRICT_MODE__ =
|
||||
disableSecondConsoleLogDimmingInStrictMode;
|
||||
}
|
||||
|
||||
finishActivation(contentWindow, bridge);
|
||||
|
||||
@@ -733,4 +733,85 @@ describe('console', () => {
|
||||
: 'in Child (at **)\n in Intermediate (at **)\n in Parent (at **)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not dim console logs if disableSecondConsoleLogDimmingInStrictMode is enabled', () => {
|
||||
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.appendComponentStack = false;
|
||||
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.hideConsoleLogsInStrictMode =
|
||||
false;
|
||||
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.disableSecondConsoleLogDimmingInStrictMode =
|
||||
true;
|
||||
|
||||
const container = document.createElement('div');
|
||||
const root = ReactDOMClient.createRoot(container);
|
||||
|
||||
function App() {
|
||||
console.log('log');
|
||||
console.warn('warn');
|
||||
console.error('error');
|
||||
return <div />;
|
||||
}
|
||||
|
||||
act(() =>
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
),
|
||||
);
|
||||
|
||||
// Both logs should be called (double logging)
|
||||
expect(global.consoleLogMock).toHaveBeenCalledTimes(2);
|
||||
expect(global.consoleWarnMock).toHaveBeenCalledTimes(2);
|
||||
expect(global.consoleErrorMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The second log should NOT have dimming (no ANSI codes)
|
||||
expect(global.consoleLogMock.mock.calls[1]).toEqual(['log']);
|
||||
expect(global.consoleWarnMock.mock.calls[1]).toEqual(['warn']);
|
||||
expect(global.consoleErrorMock.mock.calls[1]).toEqual(['error']);
|
||||
});
|
||||
|
||||
it('should dim console logs if disableSecondConsoleLogDimmingInStrictMode is disabled', () => {
|
||||
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.appendComponentStack = false;
|
||||
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.hideConsoleLogsInStrictMode =
|
||||
false;
|
||||
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.settings.disableSecondConsoleLogDimmingInStrictMode =
|
||||
false;
|
||||
|
||||
const container = document.createElement('div');
|
||||
const root = ReactDOMClient.createRoot(container);
|
||||
|
||||
function App() {
|
||||
console.log('log');
|
||||
console.warn('warn');
|
||||
console.error('error');
|
||||
return <div />;
|
||||
}
|
||||
|
||||
act(() =>
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
),
|
||||
);
|
||||
|
||||
// Both logs should be called (double logging)
|
||||
expect(global.consoleLogMock).toHaveBeenCalledTimes(2);
|
||||
expect(global.consoleWarnMock).toHaveBeenCalledTimes(2);
|
||||
expect(global.consoleErrorMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The second log should have dimming (ANSI codes present)
|
||||
expect(global.consoleLogMock.mock.calls[1]).toEqual([
|
||||
'\x1b[2;38;2;124;124;124m%s\x1b[0m',
|
||||
'log',
|
||||
]);
|
||||
expect(global.consoleWarnMock.mock.calls[1]).toEqual([
|
||||
'\x1b[2;38;2;124;124;124m%s\x1b[0m',
|
||||
'warn',
|
||||
]);
|
||||
expect(global.consoleErrorMock.mock.calls[1]).toEqual([
|
||||
'\x1b[2;38;2;124;124;124m%s\x1b[0m',
|
||||
'error',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,6 +248,7 @@ beforeEach(() => {
|
||||
breakOnConsoleErrors: false,
|
||||
showInlineWarningsAndErrors: true,
|
||||
hideConsoleLogsInStrictMode: false,
|
||||
disableSecondConsoleLogDimmingInStrictMode: false,
|
||||
});
|
||||
|
||||
const bridgeListeners = [];
|
||||
|
||||
@@ -597,4 +597,5 @@ export type DevToolsHookSettings = {
|
||||
breakOnConsoleErrors: boolean,
|
||||
showInlineWarningsAndErrors: boolean,
|
||||
hideConsoleLogsInStrictMode: boolean,
|
||||
disableSecondConsoleLogDimmingInStrictMode: boolean,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,10 @@ export default function DebuggingSettings({
|
||||
useState(usedHookSettings.hideConsoleLogsInStrictMode);
|
||||
const [showInlineWarningsAndErrors, setShowInlineWarningsAndErrors] =
|
||||
useState(usedHookSettings.showInlineWarningsAndErrors);
|
||||
const [
|
||||
disableSecondConsoleLogDimmingInStrictMode,
|
||||
setDisableSecondConsoleLogDimmingInStrictMode,
|
||||
] = useState(usedHookSettings.disableSecondConsoleLogDimmingInStrictMode);
|
||||
|
||||
useEffect(() => {
|
||||
store.setShouldShowWarningsAndErrors(showInlineWarningsAndErrors);
|
||||
@@ -47,6 +51,7 @@ export default function DebuggingSettings({
|
||||
breakOnConsoleErrors,
|
||||
showInlineWarningsAndErrors,
|
||||
hideConsoleLogsInStrictMode,
|
||||
disableSecondConsoleLogDimmingInStrictMode,
|
||||
});
|
||||
}, [
|
||||
store,
|
||||
@@ -54,6 +59,7 @@ export default function DebuggingSettings({
|
||||
breakOnConsoleErrors,
|
||||
showInlineWarningsAndErrors,
|
||||
hideConsoleLogsInStrictMode,
|
||||
disableSecondConsoleLogDimmingInStrictMode,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -105,9 +111,12 @@ export default function DebuggingSettings({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideConsoleLogsInStrictMode}
|
||||
onChange={({currentTarget}) =>
|
||||
setHideConsoleLogsInStrictMode(currentTarget.checked)
|
||||
}
|
||||
onChange={({currentTarget}) => {
|
||||
setHideConsoleLogsInStrictMode(currentTarget.checked);
|
||||
if (currentTarget.checked) {
|
||||
setDisableSecondConsoleLogDimmingInStrictMode(false);
|
||||
}
|
||||
}}
|
||||
className={styles.SettingRowCheckbox}
|
||||
/>
|
||||
Hide logs during additional invocations in
|
||||
@@ -120,6 +129,40 @@ export default function DebuggingSettings({
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
hideConsoleLogsInStrictMode
|
||||
? `${styles.SettingDisabled} ${styles.SettingWrapper}`
|
||||
: styles.SettingWrapper
|
||||
}>
|
||||
<label
|
||||
className={
|
||||
hideConsoleLogsInStrictMode
|
||||
? `${styles.SettingDisabled} ${styles.SettingRow}`
|
||||
: styles.SettingRow
|
||||
}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableSecondConsoleLogDimmingInStrictMode}
|
||||
disabled={hideConsoleLogsInStrictMode}
|
||||
onChange={({currentTarget}) =>
|
||||
setDisableSecondConsoleLogDimmingInStrictMode(
|
||||
currentTarget.checked,
|
||||
)
|
||||
}
|
||||
className={styles.SettingRowCheckbox}
|
||||
/>
|
||||
Disable log dimming during additional invocations in
|
||||
<a
|
||||
className={styles.StrictModeLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://react.dev/reference/react/StrictMode">
|
||||
Strict Mode
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
margin: 0.125rem 0.25rem 0.125rem 0;
|
||||
}
|
||||
|
||||
.SettingDisabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.OptionGroup {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
31
packages/react-devtools-shared/src/hook.js
vendored
31
packages/react-devtools-shared/src/hook.js
vendored
@@ -367,17 +367,22 @@ export function installHook(
|
||||
return;
|
||||
}
|
||||
|
||||
// Dim the text color of the double logs if we're not hiding them.
|
||||
// Firefox doesn't support ANSI escape sequences
|
||||
if (__IS_FIREFOX__) {
|
||||
originalMethod(
|
||||
...formatWithStyles(args, FIREFOX_CONSOLE_DIMMING_COLOR),
|
||||
);
|
||||
if (settings.disableSecondConsoleLogDimmingInStrictMode) {
|
||||
// Don't dim the console logs
|
||||
originalMethod(...args);
|
||||
} else {
|
||||
originalMethod(
|
||||
ANSI_STYLE_DIMMING_TEMPLATE,
|
||||
...formatConsoleArguments(...args),
|
||||
);
|
||||
// Dim the text color of the double logs if we're not hiding them.
|
||||
// Firefox doesn't support ANSI escape sequences
|
||||
if (__IS_FIREFOX__) {
|
||||
originalMethod(
|
||||
...formatWithStyles(args, FIREFOX_CONSOLE_DIMMING_COLOR),
|
||||
);
|
||||
} else {
|
||||
originalMethod(
|
||||
ANSI_STYLE_DIMMING_TEMPLATE,
|
||||
...formatConsoleArguments(...args),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -579,7 +584,10 @@ export function installHook(
|
||||
debugger;
|
||||
}
|
||||
|
||||
if (isRunningDuringStrictModeInvocation) {
|
||||
if (
|
||||
isRunningDuringStrictModeInvocation &&
|
||||
!settings.disableSecondConsoleLogDimmingInStrictMode
|
||||
) {
|
||||
// Dim the text color of the double logs if we're not hiding them.
|
||||
// Firefox doesn't support ANSI escape sequences
|
||||
if (__IS_FIREFOX__) {
|
||||
@@ -667,6 +675,7 @@ export function installHook(
|
||||
breakOnConsoleErrors: false,
|
||||
showInlineWarningsAndErrors: true,
|
||||
hideConsoleLogsInStrictMode: false,
|
||||
disableSecondConsoleLogDimmingInStrictMode: false,
|
||||
};
|
||||
patchConsoleForErrorsAndWarnings();
|
||||
} else {
|
||||
|
||||
@@ -235,6 +235,31 @@ function warnForPropDifference(
|
||||
}
|
||||
}
|
||||
|
||||
function hasViewTransition(htmlElement: HTMLElement): boolean {
|
||||
return !!(
|
||||
htmlElement.getAttribute('vt-share') ||
|
||||
htmlElement.getAttribute('vt-exit') ||
|
||||
htmlElement.getAttribute('vt-enter') ||
|
||||
htmlElement.getAttribute('vt-update')
|
||||
);
|
||||
}
|
||||
|
||||
function isExpectedViewTransitionName(htmlElement: HTMLElement): boolean {
|
||||
if (!hasViewTransition(htmlElement)) {
|
||||
// We didn't expect to see a view transition name applied.
|
||||
return false;
|
||||
}
|
||||
const expectedVtName = htmlElement.getAttribute('vt-name');
|
||||
const actualVtName: string = (htmlElement.style: any)['view-transition-name'];
|
||||
if (expectedVtName) {
|
||||
return expectedVtName === actualVtName;
|
||||
} else {
|
||||
// Auto-generated name.
|
||||
// TODO: If Fizz starts applying a prefix to this name, we need to consider that.
|
||||
return actualVtName.startsWith('_T_');
|
||||
}
|
||||
}
|
||||
|
||||
function warnForExtraAttributes(
|
||||
domElement: Element,
|
||||
attributeNames: Set<string>,
|
||||
@@ -242,10 +267,28 @@ function warnForExtraAttributes(
|
||||
) {
|
||||
if (__DEV__) {
|
||||
attributeNames.forEach(function (attributeName) {
|
||||
serverDifferences[getPropNameFromAttributeName(attributeName)] =
|
||||
attributeName === 'style'
|
||||
? getStylesObjectFromElement(domElement)
|
||||
: domElement.getAttribute(attributeName);
|
||||
if (attributeName === 'style') {
|
||||
if (domElement.getAttribute(attributeName) === '') {
|
||||
// Skip empty style. It's fine.
|
||||
return;
|
||||
}
|
||||
const htmlElement = ((domElement: any): HTMLElement);
|
||||
const style = htmlElement.style;
|
||||
const isOnlyVTStyles =
|
||||
(style.length === 1 && style[0] === 'view-transition-name') ||
|
||||
(style.length === 2 &&
|
||||
style[0] === 'view-transition-class' &&
|
||||
style[1] === 'view-transition-name');
|
||||
if (isOnlyVTStyles && isExpectedViewTransitionName(htmlElement)) {
|
||||
// If the only extra style was the view-transition-name that we applied from the Fizz
|
||||
// runtime, then we should ignore it.
|
||||
} else {
|
||||
serverDifferences.style = getStylesObjectFromElement(domElement);
|
||||
}
|
||||
} else {
|
||||
serverDifferences[getPropNameFromAttributeName(attributeName)] =
|
||||
domElement.getAttribute(attributeName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1977,13 +2020,21 @@ function getStylesObjectFromElement(domElement: Element): {
|
||||
[styleName: string]: string,
|
||||
} {
|
||||
const serverValueInObjectForm: {[prop: string]: string} = {};
|
||||
const style = ((domElement: any): HTMLElement).style;
|
||||
const htmlElement: HTMLElement = (domElement: any);
|
||||
const style = htmlElement.style;
|
||||
for (let i = 0; i < style.length; i++) {
|
||||
const styleName: string = style[i];
|
||||
// TODO: We should use the original prop value here if it is equivalent.
|
||||
// TODO: We could use the original client capitalization if the equivalent
|
||||
// other capitalization exists in the DOM.
|
||||
serverValueInObjectForm[styleName] = style.getPropertyValue(styleName);
|
||||
if (
|
||||
styleName === 'view-transition-name' &&
|
||||
isExpectedViewTransitionName(htmlElement)
|
||||
) {
|
||||
// This is a view transition name added by the Fizz runtime, not the user's props.
|
||||
} else {
|
||||
serverValueInObjectForm[styleName] = style.getPropertyValue(styleName);
|
||||
}
|
||||
}
|
||||
return serverValueInObjectForm;
|
||||
}
|
||||
@@ -2018,6 +2069,20 @@ function diffHydratedStyles(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
// Trailing semi-colon means this was regenerated.
|
||||
normalizedServerValue[normalizedServerValue.length - 1] === ';' &&
|
||||
// TODO: Should we just ignore any style if the style as been manipulated?
|
||||
hasViewTransition((domElement: any))
|
||||
) {
|
||||
// If this had a view transition we might have applied a view transition
|
||||
// name/class and removed it. If that happens, the style attribute gets
|
||||
// regenerated from the style object. This means we've lost the format
|
||||
// that we sent from the server and is unable to diff it. We just treat
|
||||
// it as passing even if it should be a mismatch in this edge case.
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, we create the object from the DOM for the diff view.
|
||||
serverDifferences.style = getStylesObjectFromElement(domElement);
|
||||
}
|
||||
|
||||
@@ -2189,4 +2189,68 @@ describe('ReactUse', () => {
|
||||
expect(root).toMatchRenderedOutput('Updated');
|
||||
},
|
||||
);
|
||||
|
||||
it('regression: does not get stuck after resolving nested Suspense', async () => {
|
||||
let resolve;
|
||||
const promiseA = Promise.resolve({
|
||||
text: 'A',
|
||||
inner: new Promise(r => {
|
||||
resolve = r;
|
||||
}),
|
||||
});
|
||||
const promiseB = Promise.resolve({
|
||||
text: 'B',
|
||||
inner: new Promise(() => {}),
|
||||
});
|
||||
|
||||
function AsyncOuter({promise}) {
|
||||
const data = use(promise);
|
||||
return (
|
||||
<>
|
||||
<Text text={data.text} />
|
||||
<Suspense fallback={<Text text="(loading inner)" />}>
|
||||
<AsyncInner promise={data.inner} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AsyncInner({promise}) {
|
||||
use(promise);
|
||||
return <Text text="(inner)" />;
|
||||
}
|
||||
|
||||
let update;
|
||||
function App() {
|
||||
const [promise, setPromise] = useState(promiseA);
|
||||
update = setPromise;
|
||||
return (
|
||||
<Suspense fallback={<Text text="(loading outer)" />}>
|
||||
<AsyncOuter promise={promise} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot();
|
||||
await act(() => {
|
||||
startTransition(() => {
|
||||
root.render(<App />);
|
||||
});
|
||||
});
|
||||
assertLog(['A', '(loading inner)']);
|
||||
expect(root).toMatchRenderedOutput('A(loading inner)');
|
||||
|
||||
// Resolve inner data
|
||||
await act(() => resolve());
|
||||
assertLog(['(inner)']);
|
||||
expect(root).toMatchRenderedOutput('A(inner)');
|
||||
|
||||
// Switch to B. Should show B, but inner is pending again.
|
||||
// BUG: The UI gets stuck on A.
|
||||
await act(() => {
|
||||
startTransition(() => update(promiseB));
|
||||
});
|
||||
assertLog(['B', '(loading inner)']);
|
||||
expect(root).toMatchRenderedOutput('B(loading inner)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,10 @@ function normalizeCodeLocInfo(str) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSerializedContent(str) {
|
||||
return str.replaceAll(__REACT_ROOT_PATH_TEST__, '**');
|
||||
}
|
||||
|
||||
describe('ReactFlightDOMEdge', () => {
|
||||
beforeEach(() => {
|
||||
// Mock performance.now for timing tests
|
||||
@@ -481,8 +485,10 @@ describe('ReactFlightDOMEdge', () => {
|
||||
);
|
||||
const [stream1, stream2] = passThrough(stream).tee();
|
||||
|
||||
const serializedContent = await readResult(stream1);
|
||||
expect(serializedContent.length).toBeLessThan(1100);
|
||||
const serializedContent = normalizeSerializedContent(
|
||||
await readResult(stream1),
|
||||
);
|
||||
expect(serializedContent.length).toBeLessThan(1075);
|
||||
|
||||
const result = await ReactServerDOMClient.createFromReadableStream(
|
||||
stream2,
|
||||
@@ -551,9 +557,11 @@ describe('ReactFlightDOMEdge', () => {
|
||||
);
|
||||
const [stream1, stream2] = passThrough(stream).tee();
|
||||
|
||||
const serializedContent = await readResult(stream1);
|
||||
const serializedContent = normalizeSerializedContent(
|
||||
await readResult(stream1),
|
||||
);
|
||||
|
||||
expect(serializedContent.length).toBeLessThan(490);
|
||||
expect(serializedContent.length).toBeLessThan(465);
|
||||
expect(timesRendered).toBeLessThan(5);
|
||||
|
||||
const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
|
||||
@@ -623,8 +631,10 @@ describe('ReactFlightDOMEdge', () => {
|
||||
);
|
||||
const [stream1, stream2] = passThrough(stream).tee();
|
||||
|
||||
const serializedContent = await readResult(stream1);
|
||||
expect(serializedContent.length).toBeLessThan(__DEV__ ? 680 : 400);
|
||||
const serializedContent = normalizeSerializedContent(
|
||||
await readResult(stream1),
|
||||
);
|
||||
expect(serializedContent.length).toBeLessThan(__DEV__ ? 630 : 400);
|
||||
expect(timesRendered).toBeLessThan(5);
|
||||
|
||||
const model = await serverAct(() =>
|
||||
@@ -657,8 +667,10 @@ describe('ReactFlightDOMEdge', () => {
|
||||
<ServerComponent recurse={20} />,
|
||||
),
|
||||
);
|
||||
const serializedContent = await readResult(stream);
|
||||
const expectedDebugInfoSize = __DEV__ ? 320 * 20 : 0;
|
||||
const serializedContent = normalizeSerializedContent(
|
||||
await readResult(stream),
|
||||
);
|
||||
const expectedDebugInfoSize = __DEV__ ? 295 * 20 : 0;
|
||||
expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
|
||||
});
|
||||
|
||||
|
||||
@@ -650,6 +650,17 @@ describe('ReactFlightDOMReply', () => {
|
||||
expect(root.prop.obj).toBe(root.prop);
|
||||
});
|
||||
|
||||
it('can transport cyclic arrays', async () => {
|
||||
const obj = {};
|
||||
const cyclic = [obj];
|
||||
cyclic[1] = cyclic;
|
||||
|
||||
const body = await ReactServerDOMClient.encodeReply({prop: cyclic, obj});
|
||||
const root = await ReactServerDOMServer.decodeReply(body, webpackServerMap);
|
||||
expect(root.prop[1]).toBe(root.prop);
|
||||
expect(root.prop[0]).toBe(root.obj);
|
||||
});
|
||||
|
||||
it('can abort an unresolved model and get the partial result', async () => {
|
||||
const promise = new Promise(r => {});
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -133,14 +133,20 @@ ReactPromise.prototype.then = function <T>(
|
||||
// Recursively check if the value is itself a ReactPromise and if so if it points
|
||||
// back to itself. This helps catch recursive thenables early error.
|
||||
let cycleProtection = 0;
|
||||
const visited = new Set<typeof ReactPromise>();
|
||||
while (inspectedValue instanceof ReactPromise) {
|
||||
cycleProtection++;
|
||||
if (inspectedValue === chunk || cycleProtection > 1000) {
|
||||
if (
|
||||
inspectedValue === chunk ||
|
||||
visited.has(inspectedValue) ||
|
||||
cycleProtection > 1000
|
||||
) {
|
||||
if (typeof reject === 'function') {
|
||||
reject(new Error('Cannot have cyclic thenables.'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
visited.add(inspectedValue);
|
||||
if (inspectedValue.status === INITIALIZED) {
|
||||
inspectedValue = inspectedValue.value;
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
resetAllUnexpectedConsoleCalls,
|
||||
patchConsoleMethods,
|
||||
} = require('internal-test-utils/consoleMock');
|
||||
const path = require('path');
|
||||
|
||||
if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
|
||||
// Inside the class equivalence tester, we have a custom environment, let's
|
||||
@@ -18,6 +19,9 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
|
||||
const spyOn = jest.spyOn;
|
||||
const noop = jest.fn;
|
||||
|
||||
// Can be used to normalize paths in stackframes
|
||||
global.__REACT_ROOT_PATH_TEST__ = path.resolve(__dirname, '../..');
|
||||
|
||||
// Spying on console methods in production builds can mask errors.
|
||||
// This is why we added an explicit spyOnDev() helper.
|
||||
// It's too easy to accidentally use the more familiar spyOn() helper though,
|
||||
|
||||
Reference in New Issue
Block a user