Skip to content

Commit c88bf29

Browse files
authored
no-unnecessary-polyfills: Improve performance (#2874)
1 parent 84246ec commit c88bf29

3 files changed

Lines changed: 269 additions & 8 deletions

File tree

rules/no-unnecessary-polyfills.js

Lines changed: 190 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,56 @@ const messages = {
1414
'All polyfilled features imported from `{{coreJsModule}}` are available as built-ins. Use the built-ins instead.',
1515
};
1616

17-
const additionalPolyfillPatterns = {
18-
'es.promise.finally': '|(p-finally)',
19-
'es.object.set-prototype-of': '|(setprototypeof)',
20-
'es.string.code-point-at': '|(code-point-at)',
17+
const additionalPolyfillModules = {
18+
'es.promise.finally': ['p-finally'],
19+
'es.object.set-prototype-of': ['setprototypeof'],
20+
'es.string.code-point-at': ['code-point-at'],
2121
};
22+
const additionalPolyfillPatterns = Object.fromEntries(
23+
Object.entries(additionalPolyfillModules).map(([feature, modules]) => [feature, `|(${modules.join('|')})`]),
24+
);
2225

2326
const prefixes = '(mdn-polyfills/|polyfill-)';
2427
const suffixes = '(-polyfill)';
2528
const delimiter = String.raw`(\.|-|\.prototype\.|/)?`;
29+
const moduleDelimiter = /[./-]/u;
30+
31+
const getFirstSegment = value => {
32+
const [firstSegment = ''] = value.split(moduleDelimiter);
33+
return firstSegment;
34+
};
35+
36+
const stripPolyfillPrefix = value => {
37+
if (value.startsWith('polyfill-')) {
38+
return value.slice('polyfill-'.length);
39+
}
40+
41+
if (value.startsWith('mdn-polyfills/')) {
42+
return value.slice('mdn-polyfills/'.length);
43+
}
44+
45+
return value;
46+
};
47+
48+
function addPolyfillToken(tokens, value) {
49+
if (!value) {
50+
return;
51+
}
52+
53+
const lowercaseValue = value.toLowerCase();
54+
tokens.add(lowercaseValue);
55+
tokens.add(getFirstSegment(lowercaseValue));
56+
57+
const camelCasedValue = camelCase(value).toLowerCase();
58+
tokens.add(camelCasedValue);
59+
tokens.add(getFirstSegment(camelCasedValue));
60+
}
2661

2762
const polyfills = Object.keys(compatData).map(feature => {
28-
let [ecmaVersion, constructorName, methodName = ''] = feature.split('.');
63+
const [rawEcmaVersion, rawConstructorName, rawMethodName = ''] = feature.split('.');
64+
let ecmaVersion = rawEcmaVersion;
65+
let constructorName = rawConstructorName;
66+
let methodName = rawMethodName;
2967

3068
if (ecmaVersion === 'es') {
3169
ecmaVersion = String.raw`(es\d*)`;
@@ -49,8 +87,145 @@ const polyfills = Object.keys(compatData).map(feature => {
4987
return {
5088
feature,
5189
pattern: new RegExp(patterns.join(''), 'i'),
90+
tokens: (() => {
91+
const tokens = new Set();
92+
93+
if (rawEcmaVersion === 'es') {
94+
tokens.add('es');
95+
} else {
96+
addPolyfillToken(tokens, rawEcmaVersion);
97+
}
98+
99+
addPolyfillToken(tokens, rawConstructorName);
100+
addPolyfillToken(tokens, rawMethodName);
101+
102+
for (const module of additionalPolyfillModules[feature] || []) {
103+
addPolyfillToken(tokens, module);
104+
}
105+
106+
return tokens;
107+
})(),
52108
};
53109
});
110+
const polyfillsByToken = new Map();
111+
const polyfillTokensByFirstCharacter = new Map();
112+
const esConstructorTokens = new Set();
113+
114+
for (const polyfill of polyfills) {
115+
const [ecmaVersion, constructorName] = polyfill.feature.split('.');
116+
if (ecmaVersion === 'es') {
117+
esConstructorTokens.add(constructorName.toLowerCase());
118+
esConstructorTokens.add(camelCase(constructorName).toLowerCase());
119+
}
120+
121+
for (const token of polyfill.tokens) {
122+
if (!token) {
123+
continue;
124+
}
125+
126+
if (polyfillsByToken.has(token)) {
127+
polyfillsByToken.get(token).push(polyfill);
128+
} else {
129+
polyfillsByToken.set(token, [polyfill]);
130+
}
131+
132+
const firstCharacter = token[0];
133+
if (polyfillTokensByFirstCharacter.has(firstCharacter)) {
134+
polyfillTokensByFirstCharacter.get(firstCharacter).add(token);
135+
} else {
136+
polyfillTokensByFirstCharacter.set(firstCharacter, new Set([token]));
137+
}
138+
}
139+
}
140+
141+
const hasEsConstructorPrefix = value => {
142+
for (const token of esConstructorTokens) {
143+
if (value.startsWith(token)) {
144+
return true;
145+
}
146+
}
147+
148+
return false;
149+
};
150+
151+
const isPotentialEsPrefix = importedModule => {
152+
if (!importedModule.startsWith('es')) {
153+
return false;
154+
}
155+
156+
let constructorIndex = 2;
157+
while (
158+
constructorIndex < importedModule.length
159+
&& importedModule[constructorIndex] >= '0'
160+
&& importedModule[constructorIndex] <= '9'
161+
) {
162+
constructorIndex++;
163+
}
164+
165+
if (importedModule.startsWith('.prototype.', constructorIndex)) {
166+
constructorIndex += '.prototype.'.length;
167+
} else if (['.', '-', '/'].includes(importedModule[constructorIndex])) {
168+
constructorIndex++;
169+
}
170+
171+
return hasEsConstructorPrefix(importedModule.slice(constructorIndex));
172+
};
173+
174+
const getPolyfillCandidates = importedModule => {
175+
const normalizedImportedModule = stripPolyfillPrefix(importedModule);
176+
if (!normalizedImportedModule) {
177+
return;
178+
}
179+
180+
const firstCharacter = normalizedImportedModule[0];
181+
const tokens = polyfillTokensByFirstCharacter.get(firstCharacter);
182+
if (!tokens) {
183+
return;
184+
}
185+
186+
const candidates = new Set();
187+
const firstSegment = getFirstSegment(normalizedImportedModule);
188+
if (firstSegment === normalizedImportedModule) {
189+
for (const token of tokens) {
190+
if (token === 'es') {
191+
if (!isPotentialEsPrefix(normalizedImportedModule)) {
192+
continue;
193+
}
194+
} else if (!normalizedImportedModule.startsWith(token)) {
195+
continue;
196+
}
197+
198+
for (const polyfill of polyfillsByToken.get(token)) {
199+
candidates.add(polyfill);
200+
}
201+
}
202+
} else {
203+
for (const token of tokens) {
204+
if (
205+
token === 'es'
206+
|| !firstSegment.startsWith(token)
207+
) {
208+
continue;
209+
}
210+
211+
for (const polyfill of polyfillsByToken.get(token)) {
212+
candidates.add(polyfill);
213+
}
214+
}
215+
}
216+
217+
if (isPotentialEsPrefix(normalizedImportedModule)) {
218+
for (const polyfill of polyfillsByToken.get('es') || []) {
219+
candidates.add(polyfill);
220+
}
221+
}
222+
223+
if (candidates.size === 0) {
224+
return;
225+
}
226+
227+
return [...candidates];
228+
};
54229

55230
function getTargets(options, dirname) {
56231
if (options?.targets) {
@@ -81,11 +256,13 @@ function create(context) {
81256
return;
82257
}
83258

259+
const unavailableFeatureSet = new Set(unavailableFeatures);
260+
84261
// When core-js graduates a feature from `esnext` to `es`, the entries list both (e.g. `['es.regexp.escape', 'esnext.regexp.escape']`),
85262
// but `coreJsCompat` only includes the `es` version in its unavailable list, making the `esnext` version appear "available".
86263
// To avoid false positives, treat `esnext.*` features as unavailable when their `es.*` counterpart is already in the list.
87264
const checkFeatures = features => !features.every(feature =>
88-
unavailableFeatures.includes(feature)
265+
unavailableFeatureSet.has(feature)
89266
|| (feature.startsWith('esnext.') && features.includes(feature.replace('esnext.', 'es.'))),
90267
);
91268

@@ -117,14 +294,19 @@ function create(context) {
117294
},
118295
};
119296
}
120-
} else if (!unavailableFeatures.includes(coreJsModuleFeatures[0])) {
297+
} else if (!unavailableFeatureSet.has(coreJsModuleFeatures[0])) {
121298
return {node, messageId: MESSAGE_ID_POLYFILL};
122299
}
123300

124301
return;
125302
}
126303

127-
const polyfill = polyfills.find(({pattern}) => pattern.test(importedModule));
304+
const polyfillCandidates = getPolyfillCandidates(importedModule.toLowerCase());
305+
if (!polyfillCandidates) {
306+
return;
307+
}
308+
309+
const polyfill = polyfillCandidates.find(({pattern}) => pattern.test(importedModule));
128310
if (polyfill) {
129311
const [, namespace, method = ''] = polyfill.feature.split('.');
130312
const features = coreJsEntries[`core-js/full/${namespace}${method && '/'}${method}`];

test/no-unnecessary-polyfills.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,26 @@ test({
117117
options: [{targets: 'node >15'}],
118118
errors: [{message: 'Use built-in instead.'}],
119119
},
120+
{
121+
code: 'require("promiseall-settled-polyfill")',
122+
options: [{targets: {node: '20'}}],
123+
errors: [{message: 'Use built-in instead.'}],
124+
},
120125
{
121126
code: 'require("es6-promise")',
122127
options: [{targets: 'node >15'}],
123128
errors: [{message: 'Use built-in instead.'}],
124129
},
130+
{
131+
code: 'require("es.prototype.array.find")',
132+
options: [{targets: {node: '20'}}],
133+
errors: [{message: 'Use built-in instead.'}],
134+
},
135+
{
136+
code: 'require("polyfill-es.prototype.array.find")',
137+
options: [{targets: {node: '20'}}],
138+
errors: [{message: 'Use built-in instead.'}],
139+
},
125140
{
126141
code: 'require("object-assign")',
127142
options: [{targets: 'node 6'}],
@@ -167,6 +182,11 @@ test({
167182
options: [{targets: 'node 4'}],
168183
errors: [{message: 'Use built-in instead.'}],
169184
},
185+
{
186+
code: 'require("arrayevery-polyfill")',
187+
options: [{targets: {node: '20'}}],
188+
errors: [{message: 'Use built-in instead.'}],
189+
},
170190
{
171191
code: 'require("mdn-polyfills/Array.prototype.findIndex")',
172192
options: [{targets: 'node 4'}],
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import test from 'ava';
2+
import path from 'node:path';
3+
import url from 'node:url';
4+
import {execFileSync} from 'node:child_process';
5+
6+
test('No unnecessary polyfills avoids scanning every pattern for unrelated imports', t => {
7+
const cwd = path.dirname(path.dirname(path.dirname(url.fileURLToPath(import.meta.url))));
8+
const script = `
9+
import {ESLint} from 'eslint';
10+
import plugin from './index.js';
11+
12+
const lintAndCountChecks = async moduleName => {
13+
let testCount = 0;
14+
const originalTest = RegExp.prototype.test;
15+
RegExp.prototype.test = function (...arguments_) {
16+
if (typeof this.source === 'string' && this.source.includes('mdn-polyfills') && this.source.includes('polyfill-')) {
17+
testCount++;
18+
}
19+
20+
return Reflect.apply(originalTest, this, arguments_);
21+
};
22+
23+
const eslint = new ESLint({
24+
overrideConfig: {
25+
plugins: {unicorn: plugin},
26+
languageOptions: {ecmaVersion: 'latest', sourceType: 'module'},
27+
rules: {'unicorn/no-unnecessary-polyfills': ['error', {targets: {node: '20'}}]},
28+
},
29+
overrideConfigFile: true,
30+
ignore: false,
31+
});
32+
33+
try {
34+
const [result] = await eslint.lintText(\`import value from "\${moduleName}";\`, {filePath: 'fixture.js'});
35+
if (result.messages.length > 0) {
36+
throw new Error('Unexpected lint errors');
37+
}
38+
} finally {
39+
RegExp.prototype.test = originalTest;
40+
}
41+
42+
return testCount;
43+
};
44+
45+
const testCounts = {
46+
normalImport: await lintAndCountChecks('eslint-package'),
47+
polyfillPrefixImport: await lintAndCountChecks('polyfill-not-a-real-module'),
48+
};
49+
50+
console.log(JSON.stringify(testCounts));
51+
`;
52+
const output = execFileSync(process.execPath, ['--input-type=module', '-e', script], {cwd, encoding: 'utf8'});
53+
const testCounts = JSON.parse(output.trim());
54+
55+
t.true(Number.isFinite(testCounts.normalImport), `Expected numeric count output, got ${output}.`);
56+
t.true(Number.isFinite(testCounts.polyfillPrefixImport), `Expected numeric count output, got ${output}.`);
57+
t.true(testCounts.normalImport < 10, `Expected fewer than 10 polyfill regex checks for normal import, got ${testCounts.normalImport}.`);
58+
t.true(testCounts.polyfillPrefixImport < 20, `Expected fewer than 20 polyfill regex checks for polyfill-prefix import, got ${testCounts.polyfillPrefixImport}.`);
59+
});

0 commit comments

Comments
 (0)