Summary
Since @rushstack/heft-sass-plugin v0.17.0, the plugin's Sass importer can no longer resolve a plain .css file referenced from another stylesheet via @use (or @import), even though the file exists on disk and dart-sass itself supports
loading plain CSS. Compilation fails with Can't find stylesheet to import.
This worked in v0.16.0 and earlier. It regressed in #5140 , which rewrote the importer. The loss of .css import resolution appears to be an unintended side-effect of the importer rewrite.
Repro steps
- In a project built with the latest
@rushstack/heft-sass-plugin, add a plain CSS file, e.g. src/themes/tokens.css:
:root { --example: #fff; }
- Reference it from a .scss entry file via @use with an explicit .css extension:
@use './themes/tokens.css' as tokens
- Run
heft build
Expected result:
The .css file is located and loaded (parsed as plain CSS), matching dart-sass's support for @use/@import of plain CSS files — and matching the behavior of heft-sass-plugin <= 0.16.0.
Actual result:
Build fails:
[build:sass] Error: src/.../entry.scss:1:0 - Can't find stylesheet to import.
[build:sass] @use './themes/tokens.css' as tokens;
Details
In v0.16.0 the plugin used a findFileUrl FileImporter that only handled ~ package URLs and returned null for everything else, alongside loadPaths:
importers: [{
findFileUrl: async (url, context) => {
if (url[0] === '~') { /* resolve package */ }
else { return null; } // defer to Sass's own filesystem resolver
}
}],
loadPaths: importIncludePaths ?? [`${buildFolder}/node_modules`, `${buildFolder}/src`],
Returning null hands resolution back to Sass's native filesystem resolver, which resolves plain .css files. That is why @use '…css' worked.
In v0.17.0 (#5140) this was replaced with a custom canonicalize/load Importer using a heft: URL scheme, and importIncludePaths/loadPaths were removed. The heft: scheme is intentionally designed so "the SASS compiler will not try to load the resource itself", i.e. the custom importer now owns all resolution. But its candidate-extension logic only covers .sass/.scss:
https://github.com/microsoft/rushstack/blob/main/heft-plugins/heft-sass-plugin/src/SassProcessor.ts#L588-L603
private async _canonicalizeHeftInnerAsync(url: string, context: CanonicalizeContext): AsyncResolution {
if (url.endsWith('.sass') || url.endsWith('.scss')) {
// Extension is already present, so only try the exact URL or the corresponding partial
return await this._canonicalizeFileAsync(url, context);
}
// Spec says prefer .sass, but we don't use that extension
for (const candidate of [`${url}.scss`, `${url}.sass`, `${url}/index.scss`, `${url}/index.sass`]) {
const result: SyncResolution = await this._canonicalizeFileAsync(candidate, context);
if (result) {
return result;
}
}
return null;
}
For a URL ending in .css, the first branch is skipped and the loop instead probes tokens.css.scss, tokens.css.sass, tokens.css/index.scss, tokens.css/index.sass — none of which exist — so resolution returns null.
Note that .css is already in the default fileExtensions (['.sass', '.scss', '.css']), so the plugin happily compiles a .css entry file; it just can't resolve one referenced from inside another stylesheet.
Suggested fix: include .css in the exact-match branch so the literal file is attempted:
if (url.endsWith('.sass') || url.endsWith('.scss') || url.endsWith('.css')) {
return await this._canonicalizeFileAsync(url, context);
}
Standard questions
Please answer these questions to help us investigate your issue more quickly:
| Question |
Answer |
@rushstack/heft version? |
1.2.17 (@rushstack/heft-sass-plugin 1.4.1) |
| Operating system? |
Mac |
| Would you consider contributing a PR? |
yes |
Node.js version (node -v)? |
20.18.1 |
Summary
Since
@rushstack/heft-sass-pluginv0.17.0, the plugin's Sass importer can no longer resolve a plain.cssfile referenced from another stylesheet via@use(or@import), even though the file exists on disk and dart-sass itself supportsloading plain CSS. Compilation fails with
Can't find stylesheet to import.This worked in v0.16.0 and earlier. It regressed in #5140 , which rewrote the importer. The loss of
.cssimport resolution appears to be an unintended side-effect of the importer rewrite.Repro steps
@rushstack/heft-sass-plugin, add a plain CSS file, e.g.src/themes/tokens.css:@use './themes/tokens.css' as tokensheft buildExpected result:
The
.cssfile is located and loaded (parsed as plain CSS), matching dart-sass's support for @use/@import of plain CSS files — and matching the behavior of heft-sass-plugin <= 0.16.0.Actual result:
Build fails:
Details
In v0.16.0 the plugin used a
findFileUrlFileImporter that only handled~package URLs and returned null for everything else, alongside loadPaths:Returning
nullhands resolution back to Sass's native filesystem resolver, which resolves plain.cssfiles. That is why @use '…css' worked.In v0.17.0 (#5140) this was replaced with a custom canonicalize/load Importer using a heft: URL scheme, and importIncludePaths/loadPaths were removed. The
heft:scheme is intentionally designed so "the SASS compiler will not try to load the resource itself", i.e. the custom importer now owns all resolution. But its candidate-extension logic only covers.sass/.scss:https://github.com/microsoft/rushstack/blob/main/heft-plugins/heft-sass-plugin/src/SassProcessor.ts#L588-L603
For a URL ending in
.css, the first branch is skipped and the loop instead probestokens.css.scss,tokens.css.sass,tokens.css/index.scss,tokens.css/index.sass— none of which exist — so resolution returns null.Note that
.cssis already in the defaultfileExtensions (['.sass', '.scss', '.css']), so the plugin happily compiles a .css entry file; it just can't resolve one referenced from inside another stylesheet.Suggested fix: include
.cssin the exact-match branch so the literal file is attempted:Standard questions
Please answer these questions to help us investigate your issue more quickly:
@rushstack/heftversion?node -v)?