Skip to content

fix(no-for-loop): handle cached-length pattern in for loop init#2925

Open
tranhoangtu-it wants to merge 4 commits intosindresorhus:mainfrom
tranhoangtu-it:fix/no-for-loop-cached-length-pattern
Open

fix(no-for-loop): handle cached-length pattern in for loop init#2925
tranhoangtu-it wants to merge 4 commits intosindresorhus:mainfrom
tranhoangtu-it:fix/no-for-loop-cached-length-pattern

Conversation

@tranhoangtu-it
Copy link
Copy Markdown

Problem

The no-for-loop rule does not flag the cached-length pattern, which is commonly used as a micro-optimization:

for (let i = 0, j = arr.length; i < j; i += 1) {
  const element = arr[i];
  console.log(element);
}

This pattern exists widely in performance-sensitive codebases (notably eslint-plugin-react) and should be converted to for-of like the standard pattern.

Reported in #295.

Root Cause

getIndexIdentifierName returned early when the init VariableDeclaration had more than one declarator:

if (variableDeclaration.declarations.length !== 1) {
  return;
}

And getArrayIdentifierFromBinaryExpression only handled i < arr.length (a MemberExpression), not i < j where j was previously assigned arr.length.

Solution

  1. Relax the single-declarator check to allow exactly 2 declarators
  2. Add getCachedLengthIdentifier — detects when the second declarator is j = arr.length and returns both the length variable name and the array identifier
  3. Update getArrayIdentifierFromBinaryExpression — when a cachedLength is provided, also matches i < j by resolving j back to the original array

Test 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 is 0 (a literal), not a MemberExpression with .length.

Fixes #295

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
Copilot AI review requested due to automatic review settings March 28, 2026 10:17
@github-actions github-actions Bot changed the title fix(no-for-loop): handle cached-length pattern in for loop init fix(no-for-loop): handle cached-length pattern in for loop init Mar 28, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread rules/no-for-loop.js
Comment on lines +103 to +106
if (
variableDeclaration.declarations.length !== 1
&& variableDeclaration.declarations.length !== 2
) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 getIndexIdentifierName to allow 2 declarators in for loop init.
  • Add getCachedLengthIdentifier and update array detection logic to resolve i < len back 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.

Comment thread rules/no-for-loop.js
Comment on lines +123 to +154
/** 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};
};
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment thread rules/no-for-loop.js
Comment on lines +103 to 108
if (
variableDeclaration.declarations.length !== 1
&& variableDeclaration.declarations.length !== 2
) {
return;
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@sindresorhus
Copy link
Copy Markdown
Owner

I double-checked this manually and I think there are still two fixer-safety issues here.

  1. The rule now accepts a seocnd declarator whenever its init looks like something.length, even if the loop condition is still using arr.length directly. In that case the autofix drops unrelated initializer work. For example:
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 sideEffect() call completely.

  1. The new cached-length variable is not tracked when deciding whether autofix is safe. If that variable is used later in the loop body or leaks out via var, the fix removes its declaration and can break the code. For example:
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 len is left undefined afterwards.

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.
@tranhoangtu-it
Copy link
Copy Markdown
Author

Great catches! Fixed both issues:

  1. Unrelated second declarator: Added a check that the loop test actually references the cached-length variable before allowing autofix. When the test uses arr.length directly and there's an unrelated second declarator, the fix is now suppressed (the rule still reports, but won't autofix).

  2. Leaked cached-length variable: Added the cached-length variable to the someVariablesLeakOutOfTheLoop check, so if it's declared with var and used after the loop, autofix is suppressed.

Added test cases for both scenarios (invalid(7) and invalid(8) in the snapshot tests). All 120 tests pass.

@sindresorhus
Copy link
Copy Markdown
Owner

Still issues. Please use a smarter AI to do the work.


  1. The rule still autofixes when there is a seocnd declarator like len = sideEffect().length but the loop condition uses arr.length directly. I verified that this still fixes to for (const element of arr) { ... }, which drops the sideEffect() call entirely.
for (let i = 0, len = sideEffect().length; i < arr.length; i++) {
	console.log(arr[i]);
}
  1. The cached-length variable is only checked for leaking outside the loop, not for being used inside the loop body. I verified that this still autofixes and leaves len behind in the body:
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 var leak case. The fix should also bail out when the cached-length variable is referenced anywhere in the loop body.

@sindresorhus sindresorhus force-pushed the main branch 2 times, most recently from 814b622 to 0e023e3 Compare April 2, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A case not handled by no-for-loop

3 participants