SharePoint / SharePoint/sp-dev-docs

[SPFx 1.23] Sass cannot resolve bare package imports inside dependencies — no loadPaths / importIncludePaths, no workaround (regression from 1.22)

Open
#11,030 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
PowerShell
Stars
1.4k
Forks
1.1k
Avg merge
4d 12h
Merged PRs (30d)
12

Description

Target SharePoint environment

SharePoint Online

What SharePoint development model, framework, SDK or API is this about?

💥 SharePoint Framework

Developer environment

macOS

What browser(s) / client(s) have you tested
  • not applicable
Additional environment details
  • SPFx: 1.23.2 (@microsoft/spfx-web-build-rig@1.23.2, which pins @rushstack/heft-sass-plugin@1.4.1 and sass-embedded@1.85.1)
  • Node.js: v22.22.0
  • npm 11.14.1; reproduced both in a single SPFx project and in an npm workspace (monorepo)
  • Last working version: SPFx 1.22.2 (@rushstack/heft-sass-plugin@0.15.24)
Describe the bug / error

Since SPFx 1.23, the Sass build can no longer resolve bare package imports (standard Sass @use '<package>/<path>', resolved through load paths) anywhere in the stylesheet graph, including inside npm dependencies the project does not own. Unlike in 1.22, there is no configuration option to fix it.

@use '@scope/some-package/scss/variables';   // standard Sass, resolved via loadPaths
[build:sass] Error: .../node_modules/shared-styles/_index.scss:0:0 - Can't find stylesheet to import.
[build:sass] @use '@n8d/htwoo-core/lib/sass/00-base/colors/colors';
-------------------- Failed --------------------

This is the "D" / "G" part of #10854. It was confirmed there but only answered with documentation ("use pkg:"). It is still broken in 1.23.2, and "use pkg:" doesn't solve this case.

The scenario

A very common setup, especially for organizations with more than one SPFx solution:

  • A base UI library from npm ships its SCSS source as partials (variables, mixins, component styles). @n8d/htwoo-core is used below only as a concrete example. The same applies to any library that distributes SCSS: Bootstrap-based kits, Fluent-style HTML/CSS kits, a company's own component library, and so on.
  • A shared design system / theme package builds on top of it. It holds the organization's brand tokens and component overrides, and it pulls in the base library with standard Sass imports:
    // shared-styles/_index.scss
    @use '@n8d/htwoo-core/lib/sass/00-base/colors/colors' as base;
    
    This package isn't built for SPFx only. The same SCSS is compiled by its own build (Dart Sass with loadPaths: ['node_modules']), by a living styleguide or Pattern Lab, and by other front ends (React/Vite apps, Angular, static sites). That's why it uses bare imports: the form all of those toolchains understand.
  • One or more SPFx solutions consume that package, either installed from a (private) npm feed or linked in an npm/yarn/pnpm workspace (monorepo) where dependencies are hoisted to the root node_modules. The web part loads the design system scoped to its root element:
    .root { :global { @include meta.load-css('pkg:shared-styles'); } }
    

Up to SPFx 1.22 this works: pkg:/~ finds the shared package, and the bare imports inside it resolve through the Sass load paths.

In SPFx 1.23, the entry point still resolves, but the first bare import inside the shared package fails and the whole build stops. The SPFx developer can't fix it: the failing line is in a dependency, sass.json has no option for it, and the design-system team can't switch to pkg: without breaking every non-SPFx consumer. The only ways out are forking or patching the dependency, maintaining a separate SPFx-only copy of the design system, or the runtime patch below.

The same failure happens without any shared package, when a project's own SCSS uses a bare import (@use '@scope/pkg/...') that worked in 1.22.

What changed
SPFx 1.22 SPFx 1.23.x
Sass loadPaths [<project>/node_modules, <project>/src] none
importIncludePaths in config/sass.json supported removed; setting it fails schema validation (additionalProperties: false)
bare @use '<package>/…' works Can't find stylesheet to import
pkg: / ~ in @use/@import/@forward ~ only works

The SPFx Sass build now resolves only pkg: and ~ URLs (~ is rewritten to pkg:) as packages. Everything else is resolved only relative to the importing file, and with no load paths Sass has no fallback.

Why this is a severe regression and not a migration step
  1. It breaks code the developer cannot change. The documented answer (#10854) is to rewrite imports to pkg:. That only works for SCSS the project owns. Any npm package, design system or shared UI library whose SCSS uses bare imports internally now breaks the SPFx build as soon as it's imported, and nobody can rewrite imports inside node_modules. ignoreDeprecationsInDependencies doesn't help because this is a hard resolution error, not a deprecation.
  2. pkg: isn't portable, so library authors can't switch either. Bare specifiers + load paths are the one form every Sass toolchain understands: Dart Sass CLI --load-path, the Sass JS API loadPaths, webpack sass-loader, Vite, Angular CLI, Pattern Lab and so on. pkg: only works where NodePackageImporter is configured. A shared style library used by SPFx and other front ends would have to break everyone else to satisfy SPFx.
  3. There is no escape hatch. importIncludePaths was removed and now fails validation, no loadPaths option replaced it, and the Sass options are built inside the toolchain with no pass-through. Standard Sass options are missing and there's no SPFx-specific replacement either (the same concern as #10466).
  4. Monorepos / npm workspaces are hit hardest. Shared style packages in a workspace are hoisted or linked and use bare imports. This was already raised in #8916 (2023) and has now gone from awkward to impossible.
  5. It's undocumented. Neither the SPFx 1.23 release notes, "Configure Sass processing during builds", nor the Gulp → Heft migration guide mention the loss of load-path resolution. Upgrading 1.22 → 1.23 just fails with a generic error pointing into a dependency.
  6. The only workaround is fragile. The runtime patch below hooks into internal, non-public methods of the Sass build step. Newer versions of the underlying Heft Sass plugin already make those methods truly private, so the workaround will stop working with a future SPFx toolchain update. After that, the only option left is editing files in node_modules.
Related: ~ inside meta.load-css() still broken ("E" from #10854)

Still reproducible in 1.23.2: @include meta.load-css('~@scope/pkg/...') fails with Unexpected tilde in URL, because the ~pkg: rewrite only applies to @import, @use and @forward.

Workaround (works on SPFx 1.23.2, no changes in node_modules)

Heft's built-in run-script-plugin runs a script in-process before the sass task and wraps the Sass resolver: if a bare specifier can't be resolved relative to the importing file, it falls back to the existing pkg: resolution.

config/heft.json

{
  "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json",
  "extends": "@microsoft/spfx-web-build-rig/profiles/default/config/heft.json",
  "phasesByName": {
    "build": {
      "tasksByName": {
        "sass-bare-imports": {
          "taskPlugin": {
            "pluginPackage": "@rushstack/heft",
            "pluginName": "run-script-plugin",
            "options": { "scriptPath": "./config/sass-patch/sass-bare-imports.js" }
          }
        },
        "sass": { "taskDependencies": ["sass-bare-imports"] }
      }
    }
  }
}

config/sass-patch/sass-bare-imports.js

'use strict';
const path = require('path');

const PATCH_FLAG = Symbol.for('heftSassBareImportsPatch');
const BARE_SPECIFIER = /^(@[^/]+\/)?[^./:][^:]*$/;

function loadSassProcessor(projectFolder) {
  const rigFolder = path.dirname(
    require.resolve('@microsoft/spfx-web-build-rig/package.json', { paths: [projectFolder] })
  );
  const modulePath = require.resolve('@rushstack/heft-sass-plugin/lib/SassProcessor', { paths: [rigFolder] });
  return require(modulePath).SassProcessor;
}

async function runAsync({ heftConfiguration, heftTaskSession }) {
  const logger = heftTaskSession.logger;
  const SassProcessor = loadSassProcessor(heftConfiguration.buildFolderPath);

  if (!SassProcessor || typeof SassProcessor.prototype._canonicalizeAsync !== 'function') {
    logger.emitWarning(new Error('sass-bare-imports: SassProcessor._canonicalizeAsync not found; patch not applied.'));
    return;
  }
  if (SassProcessor.prototype[PATCH_FLAG]) {
    return;
  }

  const original = SassProcessor.prototype._canonicalizeAsync;
  SassProcessor.prototype._canonicalizeAsync = async function (url, context) {
    const result = await original.call(this, url, context);
    if (result || !BARE_SPECIFIER.test(url) || !context.containingUrl) {
      return result;
    }
    try {
      return await this._canonicalizePackageAsync(`pkg:${url}`, context);
    } catch {
      return null; // unknown package -> normal "Can't find stylesheet" error
    }
  };
  SassProcessor.prototype[PATCH_FLAG] = true;
}

module.exports = { runAsync };

Verified on 1.23.2: bare imports inside dependencies compile; a non-existent package still fails with the normal error; clean, incremental and heft start builds work.

References
  • #10854: original 1.22 → 1.23 Sass resolution report (D: bare specifiers, E: ~ in meta.load-css(), G: importIncludePaths); repro confirmed
  • #10466: Sass degradation gulp → Heft; standard Sass options replaced by SPFx-specific ones
  • #10834: rig silences Sass deprecations globally
  • #8916, #7951: earlier node_modules / npm workspace Sass resolution reports
Steps to reproduce
  1. Create an SPFx 1.23.2 web part project (yo @microsoft/sharepoint, React) and install any package that ships SCSS partials. @n8d/htwoo-core is only an example: npm i @n8d/htwoo-core.
  2. Create a small local style package that stands in for a shared design system and uses a standard bare import:
    shared-styles/package.json   → { "name": "shared-styles", "version": "1.0.0" }
    shared-styles/_index.scss    → @use '@n8d/htwoo-core/lib/sass/00-base/colors/colors';
    
    and install it: npm i ./shared-styles
  3. In the web part's *.module.scss, load it with the documented pkg: scheme:
    @use 'sass:meta';
    .root { :global { @include meta.load-css('pkg:shared-styles'); } }
    
  4. Run heft build → fails with Can't find stylesheet to import. @use '@n8d/htwoo-core/lib/sass/00-base/colors/colors'; inside node_modules/shared-styles/_index.scss. pkg:shared-styles itself resolves; the bare import inside the dependency doesn't.
  5. Same result with a bare import directly in the project: @use '@n8d/htwoo-core/lib/sass/00-base/colors/colors';.
  6. Add "importIncludePaths": ["node_modules"] to config/sass.json → the build fails schema validation (must NOT have additional properties).
  7. The same files build successfully on SPFx 1.22.2.
Expected behavior

Bare package imports resolve from node_modules as they did in SPFx 1.22 and as every other Sass toolchain does. At minimum, dependencies' stylesheets must compile without being modified. Specifically:

  • The SPFx Sass build falls back to node_modules resolution for bare specifiers when a relative lookup fails (Sass loadPaths semantics). Or config/sass.json supports a standard loadPaths option passed to the Sass compiler.
  • ~ works in meta.load-css() like it does in @use/@import/@forward.
  • The SPFx 1.23 release notes, "Configure Sass processing during builds" and the Gulp → Heft migration guide document the resolution change and the supported options.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the failure with the listed SPFx 1.23.2 steps and heft build, then inspect config/sass.json, the Heft Sass processing, and SassProcessor._canonicalizeAsync. Compare the 1.22 and 1.23 resolution behavior. Done means bare imports in dependencies resolve or a documented loadPaths option exists, ~ works in meta.load-css(), and the affected SPFx documentation is updated.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, sass, scss
Domain
build-system, documentation, tooling
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.