Skip to content

[heft-sass-plugin] Restore resolution of bare specifiers - #6094

Open
Ian Clanton-Thuon (iclanton) wants to merge 1 commit into
microsoft:mainfrom
iclanton:fix-sass-bare-specifier-resolution
Open

Ian Clanton-Thuon (iclanton) wants to merge 1 commit into
microsoft:mainfrom
iclanton:fix-sass-bare-specifier-resolution

Conversation

@iclanton

@iclanton Ian Clanton-Thuon (iclanton) commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Addresses SharePoint/sp-dev-docs#11030

Summary

Restores resolution of bare specifiers in Sass stylesheets, e.g. @use '@scope/pkg/theme'.

Since the move to the pkg: importer, a bare specifier only ever resolved relative to the importing file. When that failed, there was no fallback, so the build stopped with Can't find stylesheet to import. There is also no configuration option to influence resolution — loadPaths was never exposed, and the schema is additionalProperties: false.

The practical impact is that the failing @use is frequently inside a third-party package, where the consuming project cannot rewrite it. A shared design-system package that ships Sass sources and uses bare specifiers internally cannot switch to pkg: either, because pkg: is only understood where NodePackageImporter is configured — every other toolchain (Dart Sass CLI --load-path, sass-loader, Vite, Angular CLI) expects bare specifiers plus load paths.

Reported downstream against SPFx 1.23 in microsoft/sp-dev-docs#11030 (confirmed and reproduced by the SharePoint team); the same resolution change reached SPFx via @microsoft/spfx-web-build-rig, which consumes this plugin.

Changes

  • Bare specifier fallback. #canonicalizeAsync now attempts resolution relative to the importing file first, and only if that fails and the specifier is bare, falls back to the new loadPaths option and then to node_modules. Relative resolution keeps precedence, matching Sass semantics.
  • New loadPaths option in config/sass.json, resolved against the project folder. Analogous to the Sass compiler option of the same name, and provides the escape hatch that the schema previously lacked.
  • Errors stay legible. A package-resolution failure returns null rather than propagating, so Sass reports its usual Can't find stylesheet to import pointing at the offending line, instead of an internal Cannot find package "...".
  • Legacy ~ handled in the resolver. Previously the ~ → pkg: rewrite was done by a regex over @import/@use/@forward only, and any surviving tilde threw Unexpected tilde in URL. The rewrite now also happens during canonicalization, so ~ works in other constructs — most notably @include meta.load-css('~@scope/pkg').

Testing

heft test — 64 passing, 7 new.

The new tests cover resolution from node_modules, resolution inside a dependency stylesheet (the reported scenario: meta.load-css('pkg:shared-styles') where shared-styles/_index.scss contains an uneditable bare @use), ~ inside meta.load-css(), and resolution via loadPaths.

Three are guard tests rather than fix tests, pinning behavior that must not change: a file relative to the importer still wins over a same-named package; a specifier naming no installed package still produces the normal Sass diagnostic; and a bare specifier does not resolve from a folder that is not a configured load path.

I verified these tests fail for the right reason: reverting only the resolver change makes exactly the four fix-targeting tests fail — reproducing Unexpected tilde in URL: ~shared-styles — while the three guard tests pass in both states, confirming nothing was quietly widened.

Because node_modules/ is gitignored, the package fixture tree is generated at test time under the project's temp/test/, so snapshots stay checkout-independent.

Docs

README options table plus a rewritten "Sass import resolution" section documenting the resolution order and when to prefer pkg: over a bare specifier; templates/sass.json gains a commented loadPaths entry.

Since the move to the `pkg:` importer, a bare specifier such as
`@use '@scope/pkg/theme'` only ever resolved relative to the importing
file, so it failed with "Can't find stylesheet to import". There was no
configuration option to change this, and the failing import is often
inside a third-party package that the consuming project cannot edit.

- Bare specifiers now fall back to the new `loadPaths` option and then to
  `node_modules` when they do not resolve relative to the importing file.
  Relative resolution still takes precedence, matching Sass semantics.
- Package resolution failures return null instead of throwing, so Sass
  reports its usual error pointing at the offending line.
- The legacy `~` rewrite is applied in the resolver rather than only by
  the `@use`/`@import`/`@forward` preprocessor, so it also works in
  constructs such as `meta.load-css()`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b66c2e44-e9ac-4611-8c45-85cb60478f7b
@iclanton
Ian Clanton-Thuon (iclanton) force-pushed the fix-sass-bare-specifier-resolution branch from 47c1903 to d4e4123 Compare September 25, 2026 01:09
"changes": [
{
"packageName": "@rushstack/heft-sass-plugin",
"comment": "Fix a regression where a bare specifier such as `@use '@scope/pkg/theme'` could not be resolved. Bare specifiers now fall back to the new `loadPaths` option and then to `node_modules` when they do not resolve relative to the importing file. Also apply the legacy `~` rewrite in constructs other than `@use`/`@import`/`@forward`, such as `meta.load-css()`.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Per CSS and SCSS spec, @scope/pkg/theme is a relative path. Having @use perform node_module resolution is a deviation from the spec, so therefore introducing it cannot be considered "fixing a regression" when the behavior was 100% by design.

// `node_modules`, matching the behavior of the Sass `loadPaths` option and `NodePackageImporter`.
// This form is what non-Heft Sass toolchains emit, so stylesheets inside third-party packages
// frequently use it and cannot be rewritten by the consuming project.
return await this.#canonicalizeBareSpecifierAsync(url, context);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't interpret a relative specifier as an external module specifier unless the configuration explicitly asks to, since, again, this is a deviation from the import spec (the target of @import is a URL).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

3 participants