Skip to content

Commit 497519e

Browse files
authored
Add prefer-structured-clone rule (#2329)
1 parent dbb98be commit 497519e

7 files changed

+590
-0
lines changed

configs/recommended.js

+1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ module.exports = {
101101
'unicorn/prefer-string-slice': 'error',
102102
'unicorn/prefer-string-starts-ends-with': 'error',
103103
'unicorn/prefer-string-trim-start-end': 'error',
104+
'unicorn/prefer-structured-clone': 'error',
104105
'unicorn/prefer-switch': 'error',
105106
'unicorn/prefer-ternary': 'error',
106107
'unicorn/prefer-top-level-await': 'error',

docs/rules/prefer-structured-clone.md

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Prefer using `structuredClone` to create a deep clone
2+
3+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs-eslintconfigjs).
4+
5+
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
[`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) is the modern way to create a deep clone of a value.
11+
12+
## Fail
13+
14+
```js
15+
const clone = JSON.parse(JSON.stringify(foo));
16+
```
17+
18+
```js
19+
const clone = _.cloneDeep(foo);
20+
```
21+
22+
## Pass
23+
24+
```js
25+
const clone = structuredClone(foo);
26+
```
27+
28+
## Options
29+
30+
Type: `object`
31+
32+
### functions
33+
34+
Type: `string[]`
35+
36+
You can also check custom functions that creates a deep clone.
37+
38+
`_.cloneDeep()` and `lodash.cloneDeep()` are always checked.
39+
40+
Example:
41+
42+
```js
43+
{
44+
'unicorn/prefer-structured-clone': [
45+
'error',
46+
{
47+
functions: [
48+
'cloneDeep',
49+
'utils.clone'
50+
]
51+
}
52+
]
53+
}
54+
```
55+
56+
```js
57+
// eslint unicorn/prefer-structured-clone: ["error", {"functions": ["utils.clone"]}]
58+
const clone = utils.clone(foo); // Fails
59+
```

readme.md

+1
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ If you don't use the preset, ensure you use the same `env` and `parserOptions` c
209209
| [prefer-string-slice](docs/rules/prefer-string-slice.md) | Prefer `String#slice()` over `String#substr()` and `String#substring()`. || 🔧 | |
210210
| [prefer-string-starts-ends-with](docs/rules/prefer-string-starts-ends-with.md) | Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`. || 🔧 | 💡 |
211211
| [prefer-string-trim-start-end](docs/rules/prefer-string-trim-start-end.md) | Prefer `String#trimStart()` / `String#trimEnd()` over `String#trimLeft()` / `String#trimRight()`. || 🔧 | |
212+
| [prefer-structured-clone](docs/rules/prefer-structured-clone.md) | Prefer using `structuredClone` to create a deep clone. || | 💡 |
212213
| [prefer-switch](docs/rules/prefer-switch.md) | Prefer `switch` over multiple `else-if`. || 🔧 | |
213214
| [prefer-ternary](docs/rules/prefer-ternary.md) | Prefer ternary expressions over simple `if-else` statements. || 🔧 | |
214215
| [prefer-top-level-await](docs/rules/prefer-top-level-await.md) | Prefer top-level await over top-level promises and async function calls. || | 💡 |

rules/prefer-structured-clone.js

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
'use strict';
2+
const {
3+
isCommaToken,
4+
isOpeningParenToken,
5+
} = require('@eslint-community/eslint-utils');
6+
const {isCallExpression, isMethodCall} = require('./ast/index.js');
7+
const {removeParentheses} = require('./fix/index.js');
8+
const {isNodeMatchesNameOrPath} = require('./utils/index.js');
9+
10+
const MESSAGE_ID_ERROR = 'prefer-structured-clone/error';
11+
const MESSAGE_ID_SUGGESTION = 'prefer-structured-clone/suggestion';
12+
const messages = {
13+
[MESSAGE_ID_ERROR]: 'Prefer `structuredClone(…)` over `{{description}}` to create a deep clone.',
14+
[MESSAGE_ID_SUGGESTION]: 'Switch to `structuredClone(…)`.',
15+
};
16+
17+
const lodashCloneDeepFunctions = [
18+
'_.cloneDeep',
19+
'lodash.cloneDeep',
20+
];
21+
22+
/** @param {import('eslint').Rule.RuleContext} context */
23+
const create = context => {
24+
const {functions: configFunctions} = {
25+
functions: [],
26+
...context.options[0],
27+
};
28+
const functions = [...configFunctions, ...lodashCloneDeepFunctions];
29+
30+
// `JSON.parse(JSON.stringify(…))`
31+
context.on('CallExpression', callExpression => {
32+
if (!(
33+
// `JSON.stringify()`
34+
isMethodCall(callExpression, {
35+
object: 'JSON',
36+
method: 'parse',
37+
argumentsLength: 1,
38+
optionalCall: false,
39+
optionalMember: false,
40+
})
41+
// `JSON.parse()`
42+
&& isMethodCall(callExpression.arguments[0], {
43+
object: 'JSON',
44+
method: 'stringify',
45+
argumentsLength: 1,
46+
optionalCall: false,
47+
optionalMember: false,
48+
})
49+
)) {
50+
return;
51+
}
52+
53+
const jsonParse = callExpression;
54+
const jsonStringify = callExpression.arguments[0];
55+
56+
return {
57+
node: jsonParse,
58+
loc: {
59+
start: jsonParse.loc.start,
60+
end: jsonStringify.callee.loc.end,
61+
},
62+
messageId: MESSAGE_ID_ERROR,
63+
data: {
64+
description: 'JSON.parse(JSON.stringify(…))',
65+
},
66+
suggest: [
67+
{
68+
messageId: MESSAGE_ID_SUGGESTION,
69+
* fix(fixer) {
70+
yield fixer.replaceText(jsonParse.callee, 'structuredClone');
71+
72+
const {sourceCode} = context;
73+
74+
yield fixer.remove(jsonStringify.callee);
75+
yield * removeParentheses(jsonStringify.callee, fixer, sourceCode);
76+
77+
const openingParenthesisToken = sourceCode.getTokenAfter(jsonStringify.callee, isOpeningParenToken);
78+
yield fixer.remove(openingParenthesisToken);
79+
80+
const [
81+
penultimateToken,
82+
closingParenthesisToken,
83+
] = sourceCode.getLastTokens(jsonStringify, 2);
84+
85+
if (isCommaToken(penultimateToken)) {
86+
yield fixer.remove(penultimateToken);
87+
}
88+
89+
yield fixer.remove(closingParenthesisToken);
90+
},
91+
},
92+
],
93+
};
94+
});
95+
96+
// `_.cloneDeep(foo)`
97+
context.on('CallExpression', callExpression => {
98+
if (!isCallExpression(callExpression, {
99+
argumentsLength: 1,
100+
optional: false,
101+
})) {
102+
return;
103+
}
104+
105+
const {callee} = callExpression;
106+
const matchedFunction = functions.find(nameOrPath => isNodeMatchesNameOrPath(callee, nameOrPath));
107+
108+
if (!matchedFunction) {
109+
return;
110+
}
111+
112+
return {
113+
node: callee,
114+
messageId: MESSAGE_ID_ERROR,
115+
data: {
116+
description: `${matchedFunction.trim()}(…)`,
117+
},
118+
suggest: [
119+
{
120+
messageId: MESSAGE_ID_SUGGESTION,
121+
fix: fixer => fixer.replaceText(callee, 'structuredClone'),
122+
},
123+
],
124+
};
125+
});
126+
};
127+
128+
const schema = [
129+
{
130+
type: 'object',
131+
additionalProperties: false,
132+
properties: {
133+
functions: {
134+
type: 'array',
135+
uniqueItems: true,
136+
},
137+
},
138+
},
139+
];
140+
141+
/** @type {import('eslint').Rule.RuleModule} */
142+
module.exports = {
143+
create,
144+
meta: {
145+
type: 'suggestion',
146+
docs: {
147+
description: 'Prefer using `structuredClone` to create a deep clone.',
148+
},
149+
hasSuggestions: true,
150+
schema,
151+
messages,
152+
},
153+
};

test/prefer-structured-clone.mjs

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import outdent from 'outdent';
2+
import {getTester} from './utils/test.mjs';
3+
4+
const {test} = getTester(import.meta);
5+
6+
// `JSON.parse(JSON.stringify(…))`
7+
test.snapshot({
8+
valid: [
9+
'structuredClone(foo)',
10+
'JSON.parse(new JSON.stringify(foo))',
11+
'new JSON.parse(JSON.stringify(foo))',
12+
'JSON.parse(JSON.stringify())',
13+
'JSON.parse(JSON.stringify(...foo))',
14+
'JSON.parse(JSON.stringify(foo, extraArgument))',
15+
'JSON.parse(...JSON.stringify(foo))',
16+
'JSON.parse(JSON.stringify(foo), extraArgument)',
17+
'JSON.parse(JSON.stringify?.(foo))',
18+
'JSON.parse(JSON?.stringify(foo))',
19+
'JSON.parse?.(JSON.stringify(foo))',
20+
'JSON?.parse(JSON.stringify(foo))',
21+
'JSON.parse(JSON.not_stringify(foo))',
22+
'JSON.parse(not_JSON.stringify(foo))',
23+
'JSON.not_parse(JSON.stringify(foo))',
24+
'not_JSON.parse(JSON.stringify(foo))',
25+
'JSON.stringify(JSON.parse(foo))',
26+
// Not checking
27+
'JSON.parse(JSON.stringify(foo, undefined, 2))',
28+
],
29+
invalid: [
30+
'JSON.parse(JSON.stringify(foo))',
31+
'JSON.parse(JSON.stringify(foo),)',
32+
'JSON.parse(JSON.stringify(foo,))',
33+
'JSON.parse(JSON.stringify(foo,),)',
34+
'JSON.parse( ((JSON.stringify)) (foo))',
35+
'(( JSON.parse)) (JSON.stringify(foo))',
36+
'JSON.parse(JSON.stringify( ((foo)) ))',
37+
outdent`
38+
function foo() {
39+
return JSON
40+
.parse(
41+
JSON.
42+
stringify(
43+
bar,
44+
),
45+
);
46+
}
47+
`,
48+
],
49+
});
50+
51+
// Custom functions
52+
test.snapshot({
53+
valid: [
54+
'new _.cloneDeep(foo)',
55+
'notMatchedFunction(foo)',
56+
'_.cloneDeep()',
57+
'_.cloneDeep(...foo)',
58+
'_.cloneDeep(foo, extraArgument)',
59+
'_.cloneDeep?.(foo)',
60+
'_?.cloneDeep(foo)',
61+
],
62+
invalid: [
63+
'_.cloneDeep(foo)',
64+
'lodash.cloneDeep(foo)',
65+
'lodash.cloneDeep(foo,)',
66+
{
67+
code: 'myCustomDeepCloneFunction(foo,)',
68+
options: [{functions: ['myCustomDeepCloneFunction']}],
69+
},
70+
{
71+
code: 'my.cloneDeep(foo,)',
72+
options: [{functions: ['my.cloneDeep']}],
73+
},
74+
],
75+
});

0 commit comments

Comments
 (0)