Skip to content

formatter: Non-idempotent import sorting with order: "desc" when type and wildcard imports from the same module are present #23541

Description

@groomain

Input

import * as Sentry from '@sentry/browser'
import type { BrowserOptions } from '@sentry/browser'

Config

{
  "sortImports": {
    "order": "desc"
  }  
}

Oxfmt output

Oxfmt version: 0.55.0

Running oxfmt twice on the same file produces alternating outputs — it never converges:

Pass 1:

import type { BrowserOptions } from "@sentry/browser";
import * as Sentry from "@sentry/browser";

Pass 2 (reverts back to original):

import * as Sentry from "@sentry/browser";
import type { BrowserOptions } from "@sentry/browser";

Oxfmt playground link

No response

Prettier output

Prettier version: 3.x

Prettier does not sort imports natively. Expected behavior from oxfmt: the output should be stable after the first pass (idempotent), regardless of which order is chosen.

Prettier playground link

No response

Additional notes

  • The bug only occurs with "order": "desc". Switching to "order": "asc" produces stable, idempotent output.
  • The two imports resolve to the same module path (@sentry/browser), so the sort is a tie. With desc, this tie-break is unstable and oscillates on every run.

AI investigation:

fn sort_indices_by_source(
      indices: &mut [usize],
      imports: &[SortableImport],
      options: &SortImportsOptions,
  ) {
      indices.sort_by(|&a, &b| {
          natord::compare(&imports[a].normalized_source, &imports[b].normalized_source)
      });

      if options.order.is_desc() {
          indices.reverse();
      }
  }

The sort uses only normalized_source (the module path) as the key. When two imports share the same path (@sentry/browser), natord::compare returns Equal, making their
order unstable — it depends on input order.

Then .reverse() is called on the whole slice for desc. This reversal flips the relative order of equal elements each time, because:

  • After sort_by with all-equal keys, the two imports are in their original input order (e.g. wildcard, type)
  • .reverse() makes them type, wildcard
  • Next run, input is type, wildcard, sort_by preserves that, .reverse() makes them wildcard, type again

The fix is to sort in descending order directly in the comparator instead of sorting asc then reversing:

  indices.sort_by(|&a, &b| {
      let cmp = natord::compare(&imports[a].normalized_source, &imports[b].normalized_source);
      if options.order.is_desc() { cmp.reverse() } else { cmp }
  });

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