Compare commits

..

7 Commits

Author SHA1 Message Date
Dan Abramov
877c1bcbd7 Add failing test for #35387 2025-12-18 03:22:41 +09:00
Hendrik Liebau
454fc41fc7 [test] Add tests for cyclic arrays in Flight and Flight Reply (#35347)
We already had tests for cyclic objects, but not for cyclic arrays.
2025-12-17 18:08:16 +01:00
Sebastian Markbåge
f93b9fd44b Skip hydration errors when a view transition has been applied (#35380)
When the Fizz runtime runs a view-transition we apply
`view-transition-name` and `view-transition-class` to the `style`. These
can be observed by Fiber when hydrating which incorrectly leads to
hydration errors.

More over, even after we remove them, the `style` attribute has now been
normalized which we are unable to diff because we diff against the SSR
generated `style` attribute string and not the normalized form. So if
there are other inline styles defined, we have to skip diffing them in
this scenario.
2025-12-17 09:37:43 -05:00
Christian Van
b731fe28cc Improve cyclic thenable detection in ReactFlightReplyServer (#35369)
## Summary

This PR improves cyclic thenable detection in
`ReactFlightReplyServer.js`. Fixes #35368.
The previous fix only detected direct self-references (`inspectedValue
=== chunk`) and relied on the `cycleProtection` counter to eventually
bail out of longer cycles. This change keeps the existing
MAX_THENABLE_CYCLE_DEPTH ($1000$) `cycleProtection` cap as a hard
guardrail and adds a visited set so that we can detect self-cycles and
multi-node cycles as soon as any `ReactPromise` is revisited and while
still bounding the amount of work we do for deep acyclic chains via
`cycleProtection`.

## How did you test this change?

- Ran the existing test suite for the server renderer:

  ```bash
  yarn test react-server
  yarn test --prod react-server
  yarn flow dom-node
  yarn linc
  ```

---------

Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2025-12-17 12:22:26 +01:00
Jack Pope
88ee1f5955 Add reporting modes for react-hooks/exhaustive-effect-dependencies and temporarily enable (#35365)
`react-hooks/exhaustive-effect-dependencies` from
`ValidateExhaustiveDeps` reports errors for both missing and extra
effect deps. We already have `react-hooks/exhaustive-deps` that errors
on missing dependencies. In the future we'd like to consolidate this all
to the compiler based error, but for now there's a lot of overlap. Let's
enable testing the extra dep warning by splitting out reporting modes.

This PR
- Creates `on`, `off`, `missing-only`, and `extra-only` reporting modes
for the effect dep validation flag
- Temporarily enables the new rule with `extra-only` in
`eslint-plugin-react-hooks`
- Adds additional null checking to `manualMemoLoc` to fix a bug found
when running against the fixture
2025-12-15 18:59:27 -05:00
emily8rown
bcf97c7564 Devtools disable log dimming strict mode setting (#35207)
<!--

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).

-->

## Summary

Currently, every second console log is dimmed, receiving a special style
that indicates to user that it was raising because of [React Strict
Mode](https://react.dev/reference/react/StrictMode) second rendering.
This introduces a setting to disable this.

## How did you test this change?
Test in console-test.js


https://github.com/user-attachments/assets/af6663ac-f79b-4824-95c0-d46b0c8dec12

Browser extension react devtools


https://github.com/user-attachments/assets/7e2ecb7a-fbdf-4c72-ab45-7e3a1c6e5e44

React native dev tools:


https://github.com/user-attachments/assets/d875b3ac-1f27-43f8-8d9d-12b2d65fa6e6

---------

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
2025-12-15 13:41:43 +00:00
Sebastian "Sebbie" Silbermann
ba5b843692 [test] Exclude repository root from assertions (#35361) 2025-12-15 11:45:17 +01:00
36 changed files with 693 additions and 192 deletions

View File

@@ -635,6 +635,7 @@ module.exports = {
FocusOptions: 'readonly',
OptionalEffectTiming: 'readonly',
__REACT_ROOT_PATH_TEST__: 'readonly',
spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',
spyOnProd: 'readonly',

View File

@@ -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

View File

@@ -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',

View File

@@ -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);
}
}
}

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect, useEffectEvent} from 'react';
function Component({x, y, z}) {

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect, useEffectEvent} from 'react';
function Component({x, y, z}) {

View File

@@ -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]`
```

View File

@@ -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]);
}

View File

@@ -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]`
```

View File

@@ -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]);
}

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect} from 'react';
function Component({x, y, z}) {

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect} from 'react';
function Component({x, y, z}) {

View File

@@ -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,

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies
// @validateExhaustiveMemoizationDependencies @validateExhaustiveEffectDependencies:"all"
import {
useCallback,
useTransition,

View File

@@ -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) {

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveEffectDependencies
// @validateExhaustiveEffectDependencies:"all"
import {useEffect, useEffectEvent} from 'react';
function Component({x, y, z}) {

View File

@@ -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]);
}

View File

@@ -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==

View File

@@ -42,6 +42,7 @@ const COMPILER_OPTIONS: PluginOptions = {
// Temporarily enabled for internal testing
enableUseKeyedState: true,
enableVerboseNoSetStateInEffect: true,
validateExhaustiveEffectDependencies: 'extra-only',
},
};

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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.

View File

@@ -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',

View File

@@ -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);

View File

@@ -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',
]);
});
});

View File

@@ -248,6 +248,7 @@ beforeEach(() => {
breakOnConsoleErrors: false,
showInlineWarningsAndErrors: true,
hideConsoleLogsInStrictMode: false,
disableSecondConsoleLogDimmingInStrictMode: false,
});
const bridgeListeners = [];

View File

@@ -597,4 +597,5 @@ export type DevToolsHookSettings = {
breakOnConsoleErrors: boolean,
showInlineWarningsAndErrors: boolean,
hideConsoleLogsInStrictMode: boolean,
disableSecondConsoleLogDimmingInStrictMode: boolean,
};

View File

@@ -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&nbsp;
@@ -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&nbsp;
<a
className={styles.StrictModeLink}
target="_blank"
rel="noopener noreferrer"
href="https://react.dev/reference/react/StrictMode">
Strict Mode
</a>
</label>
</div>
</div>
);
}

View File

@@ -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;

View File

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

View File

@@ -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);
}

View File

@@ -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)');
});
});

View File

@@ -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);
});

View File

@@ -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();

View File

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

View File

@@ -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,