Skip to content

linter: no-useless-assignment false positives for for-loop updates and try/finally ownership flags #23519

Description

@graemefolk

What version of Oxlint are you using?

1.69.0

What command did you run?

oxlint in a repository that invokes oxlint with eslint/no-useless-assignment enabled.

What happened?

eslint/no-useless-assignment reports assignments that are used on later control-flow paths.

Repro 1: for update expression used by the next iteration

const maxRetries = 3;

async function retryUntilSuccess(run: () => Promise<void>): Promise<void> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await run();
    } catch (error) {
      if (attempt >= maxRetries) {
        throw error;
      }
    }
  }
}

Oxlint reports the attempt++ update expression as unused, but the updated value is read by the catch block on the next iteration.

Repro 2: try/finally ownership flag initial value

function makeResource(): { readonly release: () => void } {
  return { release() {} };
}

function useResource(unsafe: (resource: { readonly release: () => void }) => void): { readonly release: () => void } {
  const resource = makeResource();
  let owned = true;

  try {
    unsafe(resource);
    owned = false;
  } finally {
    if (owned) {
      resource.release();
    }
  }

  return resource;
}

Oxlint reports let owned = true as unused. That initial value is observed by the finally block if unsafe(resource) throws before ownership is transferred.

Repro 3: assignments in a loop body used by the next iteration

async function waitWithBackoff(run: () => Promise<void>): Promise<void> {
  let backoffMillis = 20;
  let releaseRequested = false;

  while (Date.now() < Date.now() + 2000) {
    try {
      return await run();
    } catch (error) {
      if (!(error instanceof Error) || !error.message.startsWith("LeaseHeldError")) {
        throw error;
      }
    }

    if (!releaseRequested) {
      releaseRequested = true;
    }

    const waitUntil = Math.min(Date.now() + backoffMillis, Date.now() + 2000);
    backoffMillis *= 2;
    await new Promise((resolve) => setTimeout(resolve, waitUntil - Date.now()));
  }
}

Oxlint reports both releaseRequested = true and backoffMillis *= 2, but both values are read on subsequent loop iterations.

ESLint documents this conservative behavior as intentional for no-useless-assignment: assignments inside/around try may be observed through exceptional control flow and should not be reported when an exception path can read the value.

What did you expect to happen?

No diagnostics for either snippet. Both assignments affect observable runtime behavior.

Notes

These were found while migrating a codebase from ESLint to oxlint.

Metadata

Metadata

Assignees

Labels

Type

Fields

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions