Skip to content

[heft-sass-plugin] Add opt-in bare specifier resolution options - #6094

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

Ian Clanton-Thuon (iclanton) wants to merge 2 commits 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

Summary

Adds two opt-in options for resolving bare specifiers in Sass stylesheets, e.g. @use '@scope/pkg/theme', and fixes the legacy ~ rewrite in constructs other than @use/@import/@forward.

Revised after review. This PR originally made node_modules resolution of bare specifiers automatic and described it as fixing a regression. As David Michon (@dmichon-msft) pointed out, that framing was wrong: per the Sass specification the target of @use, @import and @forward is a URL, so @scope/pkg/theme is a relative path, and the existing behavior was by design. Both fallbacks are now off by default and apply only when explicitly configured.

Motivation

Some other Sass toolchains — the Dart Sass CLI's --load-path, sass-loader, Vite, the Angular CLI — resolve bare specifiers from node_modules. Stylesheets authored for those toolchains rely on it, and the import is frequently inside a third-party package, where a consuming project cannot rewrite it. Today there is no way to consume such a package: loadPaths was never exposed, and sass.json is additionalProperties: false, so there is no escape hatch.

These options provide one without changing the default behavior for anyone else. Reported downstream against SPFx 1.23 in microsoft/sp-dev-docs#11030.

Changes

  • loadPaths — folders, resolved against the project folder, searched when a bare specifier does not resolve relative to the importing file. Analogous to the Sass compiler option of the same name.
  • resolveBareSpecifiersAsPackages — when true, a bare specifier that resolves neither relatively nor from loadPaths is additionally resolved as a package via Node module resolution. Defaults to false.
  • Both apply only after relative resolution has failed, so enabling them cannot change the meaning of a specifier that already resolves.
  • 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 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 @include meta.load-css('~@scope/pkg'). ~ is an explicit package reference, so it is not gated behind resolveBareSpecifiersAsPackages.

Testing

heft test — 65 passing, 8 new.

New coverage: resolution from node_modules when enabled; resolution inside a dependency stylesheet (the reported scenario); ~ inside meta.load-css(); and resolution via loadPaths.

Four are guard tests pinning behavior that must not change:

  • a bare specifier does not resolve as a package by default — this test omits the option entirely, so it asserts the default value rather than an explicit false;
  • 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;
  • a bare specifier does not resolve from a folder that is not a configured load path.

I verified the tests fail for the right reasons by mutation: flipping the resolveBareSpecifiersAsPackages default to true fails exactly the default-behavior guard and nothing else, and reverting the resolver change fails exactly the four fix-targeting tests — reproducing Unexpected tilde in URL: ~plain-styles — while the guards stay green.

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, why bare specifiers are not resolved as packages by default, and when to prefer pkg:. templates/sass.json gains commented entries for both options.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, and the change file has been rewritten. It no longer claims a regression: the options are now described as new opt-in behavior, with the package-resolution one marked minor rather than patch, and its description states that the default preserves the spec behavior in which such a specifier is a relative URL.

The ~ rewrite is split out into a separate patch entry, since that one is genuinely a defect fix rather than new behavior.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are right, and I have reworked the PR accordingly.

Package resolution of a bare specifier is now behind a new resolveBareSpecifiersAsPackages option that defaults to false, and loadPaths applies only when it is configured. With neither set, a bare specifier is resolved exactly as before: relative to the importing file, per the URL semantics in the spec. Both fallbacks also run only after relative resolution has failed, so enabling them cannot change the meaning of a specifier that already resolves.

The guard test for this omits the option entirely rather than passing an explicit false, so it asserts the default itself. I confirmed it bites by flipping the default to true in a scratch build: that fails precisely this test and nothing else.

One thing I deliberately did not gate is the legacy ~ rewrite, since ~ is an explicit package reference rather than a relative URL, and the plugin already resolves it as a package in @use/@import/@forward. The change only makes it behave consistently in constructs the preprocessor regex does not cover, such as meta.load-css(), which previously threw Unexpected tilde in URL. Happy to gate it too if you would rather it were opt-in.

Addresses review feedback: per the Sass specification the target of
`@use`/`@import`/`@forward` is a URL, so `@use '@scope/pkg/theme'` is a
relative path and resolving it from node_modules is a deviation. That
deviation is now opt-in rather than automatic.

- Add `resolveBareSpecifiersAsPackages`, defaulting to false. Package
  resolution of a bare specifier happens only when it is enabled.
- `loadPaths` is likewise consulted only when configured.
- The legacy `~` rewrite is unchanged by this commit; `~` is an explicit
  package reference, so it is not gated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b66c2e44-e9ac-4611-8c45-85cb60478f7b
@iclanton Ian Clanton-Thuon (iclanton) changed the title [heft-sass-plugin] Restore resolution of bare specifiers [heft-sass-plugin] Add opt-in bare specifier resolution options Sep 28, 2026

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