Skip to content

SCSS @use 'X' resolves to X.tsx instead of X.scss when both siblings exist #245

@OmerGronich

Description

@OmerGronich

What happened?

When an SCSS file uses a bare module specifier like @use 'Widget'; (Sass's "I'll figure out the extension" form), and there is both a Widget.tsx and a Widget.scss next to the importer, fallow resolves the import to Widget.tsx instead of Widget.scss.

This is incorrect: Sass's resolver only ever looks for Widget.scss / _Widget.scss / Widget.sass / _Widget.sass. The TypeScript file is invisible to Sass; it has no .scss content and Dart Sass / node-sass would error at build time if asked to import it. The Angular CLI, Nx, Vite, and webpack sass-loader all behave the same way.

The mis-resolution creates phantom circular dependencies when:

  1. Widget.tsx imports ./Widget.scss (standard CSS-modules / Angular styleUrls pattern).
  2. Widget.tsx imports ./Helper (a sibling component).
  3. Helper.tsx imports ./Helper.scss.
  4. Helper.scss does @use 'Widget'; — meant to share Sass variables/mixins from Widget.scss.

Fallow's graph then contains:

  • Widget.tsx → Widget.scss ✓ (correct)
  • Widget.tsx → Helper.tsx ✓ (correct)
  • Helper.tsx → Helper.scss ✓ (correct)
  • Helper.scss → Widget.tsx ❌ (should be → Widget.scss)

… which produces a 3-file cycle Helper.scss → Widget.tsx → Helper.tsx → Helper.scss that does not exist in reality.

Reproduction

  1. Create an empty directory and cd into it.

  2. Create a minimal .fallowrc.json:

    {
      "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
      "entry": ["src/main.tsx"]
    }
  3. Create package.json:

    { "name": "demo", "private": true, "version": "0.0.1" }
  4. Create src/Widget.tsx:

    import './Widget.scss';
    import { Helper } from './Helper';
    
    export function Widget() {
      return Helper();
    }
  5. Create src/Widget.scss:

    .widget { color: red; }
  6. Create src/Helper.tsx:

    import './Helper.scss';
    
    export function Helper() {
      return 'helper';
    }
  7. Create src/Helper.scss — bare @use of the sibling Sass file (the bug trigger):

    @use 'Widget';
    
    .helper { background: blue; }
  8. Create src/main.tsx:

    import { Widget } from './Widget';
    console.log(Widget());
  9. Run fallow:

    fallow dead-code --format json --quiet | jq '{cnt: (.circular_dependencies | length), cycles: .circular_dependencies}'

Expected behavior

{ "cnt": 0, "cycles": [] }

@use 'Widget'; from a .scss importer must resolve only to *.scss / *.sass / _*.scss / _*.sass candidates per the Sass resolution algorithm. Widget.tsx is not a Sass file and must not be considered.

The control case verifies this: replace @use 'Widget'; with @use './Widget.scss'; (extension-explicit) and re-run — fallow correctly reports cnt: 0. The two forms are equivalent in Sass, so they should be classified identically.

Actual behavior

{
  "cnt": 1,
  "cycles": [
    {
      "files": ["src/Helper.scss", "src/Widget.tsx", "src/Helper.tsx"],
      "length": 3,
      "line": 1,
      "col": 0,
      "actions": [
        {
          "type": "refactor-cycle",
          "auto_fixable": false,
          "description": "Extract shared logic into a separate module to break the cycle",
          "note": "Circular imports can cause initialization issues and make code harder to reason about"
        },
        ...
      ]
    }
  ]
}

The cycle includes Widget.tsx, which Helper.scss cannot possibly reach via Sass's resolution algorithm.

Suggested fix

The SCSS-specific resolver in crates/graph/src/resolve/specifier.rs:197 (fn try_scss_fallbacks) calls a chain of three SCSS-aware resolvers (partial fallback, framework include-paths, node_modules), but if none of those produce a hit, the outer resolve_specifier continues into the generic alias / source-extension fallback chain — which is where Widget.tsx is matched.

A minimal fix: when the importer's extension is .scss / .sass, the resolver should return ResolveResult::Unresolved after exhausting the SCSS-specific fallbacks, instead of falling through to the JS/TS extension list. Equivalently, the generic source-extension fallback (try_source_fallback and the inline extensions retry in resolve_specifier) should consult is_style_file(from_file) and skip JS/TS extensions when the importer is a stylesheet.

(Note: Sass has its own pkg: import scheme for cross-language resolution. Even there, the targets are *.scss only, never *.tsx.)

Fallow version

2.56.0

Operating system

macOS

Configuration

`.fallowrc.json` shown above. There is no per-file or per-extension override for the resolver; the only way to silence the resulting cycle is `rules.circular-dependency: "off"` globally (which hides every cycle, real and phantom).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions