fix(no-for-loop): handle cached-length pattern in for loop init#2925
fix(no-for-loop): handle cached-length pattern in for loop init#2925tranhoangtu-it wants to merge 4 commits intosindresorhus:mainfrom
no-for-loop): handle cached-length pattern in for loop init#2925Conversation
Extends the no-for-loop rule to flag the cached-length pattern:
for (let i = 0, j = arr.length; i < j; i += 1) { ... }
Previously, the rule only handled the direct array.length pattern:
for (let i = 0; i < arr.length; i += 1) { ... }
The fix adds a getCachedLengthIdentifier helper that detects when the
second declarator in a two-variable init is assigned the array length
(e.g. `j = arr.length`), and passes this information to
getArrayIdentifierFromBinaryExpression so it can resolve `i < j`
back to the original array.
Fixes sindresorhus#295
no-for-loop): handle cached-length pattern in for loop init
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dea0deac0f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ( | ||
| variableDeclaration.declarations.length !== 1 | ||
| && variableDeclaration.declarations.length !== 2 | ||
| ) { |
There was a problem hiding this comment.
Reject non-cached secondary declarators in loop init
Allowing declarations.length === 2 unconditionally causes false positives and unsafe autofixes for loops that have any extra initializer, not just cached length (for example for (let i = 0, tmp = sideEffect(); i < arr.length; i++)). The fixer replaces the whole header and drops the second declarator, which can remove side effects or leave tmp/j references in the body unresolved. This should be gated to the exact cached-length form before the rule proceeds.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Updates the unicorn/no-for-loop ESLint rule to also detect the “cached-length” for loop pattern (for (let i = 0, len = arr.length; i < len; i++)) so it can be converted to a for-of loop, aligning behavior with common performance-oriented codebases.
Changes:
- Extend
getIndexIdentifierNameto allow 2 declarators inforloop init. - Add
getCachedLengthIdentifierand update array detection logic to resolvei < lenback to the original array. - Add new invalid test cases covering cached-length variants.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/no-for-loop.js | Adds invalid fixtures for cached-length for loops and their expected for-of output. |
| rules/no-for-loop.js | Adds cached-length detection and extends array-resolution logic to support i < len comparisons. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** Returns the cached array identifier when init has two declarators: `let i = 0, j = arr.length` */ | ||
| const getCachedLengthIdentifier = forStatement => { | ||
| const {init: variableDeclaration} = forStatement; | ||
|
|
||
| if ( | ||
| !variableDeclaration | ||
| || variableDeclaration.type !== 'VariableDeclaration' | ||
| || variableDeclaration.declarations.length !== 2 | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| const [, lengthDeclarator] = variableDeclaration.declarations; | ||
|
|
||
| if (lengthDeclarator.id.type !== 'Identifier') { | ||
| return; | ||
| } | ||
|
|
||
| const {init} = lengthDeclarator; | ||
|
|
||
| if ( | ||
| !init | ||
| || init.type !== 'MemberExpression' | ||
| || init.object.type !== 'Identifier' | ||
| || init.property.type !== 'Identifier' | ||
| || init.property.name !== 'length' | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| return {lengthVariableName: lengthDeclarator.id.name, arrayIdentifier: init.object}; | ||
| }; |
There was a problem hiding this comment.
getCachedLengthIdentifier enables autofixing loops like for (let i = 0, len = arr.length; i < len; i++), but the fixer will remove the len = arr.length declarator entirely. If len is referenced anywhere in the loop body (for example to detect the last iteration), the autofix will break semantics. Add a guard before offering a fix (or before reporting) to ensure the cached-length variable has no reads/writes inside node.body (or preserve it in the fix).
| if ( | ||
| variableDeclaration.declarations.length !== 1 | ||
| && variableDeclaration.declarations.length !== 2 | ||
| ) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Allowing two declarators in the for init without further validation will make the rule start reporting loops that previously were intentionally ignored (e.g. for (let i = 0, j = 0; i < arr.length; i++) { ... }) and can also drop side effects/unrelated variables from the initializer when autofixing. Consider only accepting 2 declarators when the second declarator matches the cached-length pattern (<id> = <array>.length) and the loop test compares the index against that cached-length identifier; otherwise keep bailing out as before.
|
I double-checked this manually and I think there are still two fixer-safety issues here.
for (let i = 0, len = sideEffect().length; i < arr.length; i++) {
console.log(arr[i]);
}gets fixed to: for (const element of arr) {
console.log(element);
}which removes the
for (var i = 0, len = arr.length; i < len; i++) {
console.log(arr[i]);
}
console.log(len);gets fixed to: for (const element of arr) {
console.log(element);
}
console.log(len);so |
Addresses both issues raised by @sindresorhus: 1. Don't autofix when a second declarator exists but its cached-length variable isn't used in the loop test — the fix would silently drop the second declarator's init side effects. 2. Include the cached-length variable in the leak check — if it's declared with `var` and referenced after the loop, removing its declaration would break the code.
|
Great catches! Fixed both issues:
Added test cases for both scenarios (invalid(7) and invalid(8) in the snapshot tests). All 120 tests pass. |
|
Still issues. Please use a smarter AI to do the work.
for (let i = 0, len = sideEffect().length; i < arr.length; i++) {
console.log(arr[i]);
}
for (let i = 0, len = arr.length; i < len; i++) {
console.log(len, arr[i]);
}becomes: for (const element of arr) {
console.log(len, element);
}So the remaining problem is broader than the |
814b622 to
0e023e3
Compare
Problem
The
no-for-looprule does not flag the cached-length pattern, which is commonly used as a micro-optimization:This pattern exists widely in performance-sensitive codebases (notably
eslint-plugin-react) and should be converted tofor-oflike the standard pattern.Reported in #295.
Root Cause
getIndexIdentifierNamereturned early when the initVariableDeclarationhad more than one declarator:And
getArrayIdentifierFromBinaryExpressiononly handledi < arr.length(aMemberExpression), noti < jwherejwas previously assignedarr.length.Solution
getCachedLengthIdentifier— detects when the second declarator isj = arr.lengthand returns both the length variable name and the array identifiergetArrayIdentifierFromBinaryExpression— when acachedLengthis provided, also matchesi < jby resolvingjback to the original arrayTest Cases Added
Three new invalid cases covering:
for (let i = 0, j = arr.length; i < j; i++)for (let i = 0, j = arr.length; i < j; i += 1)(with named element variable)for (let i = 0, len = arr.length; i < len; i++)(different length variable name)The existing valid case
for (let i = 0, j = 0; ...)continues to pass because the second declarator's init is0(a literal), not aMemberExpressionwith.length.Fixes #295