Skip to content

[SPFx 1.22+] Undocumented breaking change: plain .scss and .css files generate typed CSS module declarations but webpack processes them as global CSS #10832

@StfBauer

Description

@StfBauer

[SPFx 1.22+] Undocumented breaking change: plain .scss and .css files generate typed CSS module declarations but webpack processes them as global CSS

Summary

In SPFx 1.22+ (Heft toolchain), heft-sass-plugin generates typed TypeScript declarations (IStyles) for all .scss and .css files, treating them as CSS modules. However, webpack only applies CSS module processing to files named *.module.scss. This creates a three-layer mismatch where TypeScript compilation succeeds, webpack emits warnings, and the browser throws TypeError at runtime when class names are accessed.

This is a silent breaking change. No migration guide exists. No tooling warning identifies affected files. Jest tests pass (identity mock hides the problem). The build succeeds. Only the running application reveals the crash.

Environment

SPFx 1.23.0
@rushstack/heft-sass-plugin 1.3.8
@microsoft/sp-css-loader 1.23.0
@microsoft/spfx-heft-plugins 1.23.0
Node.js 22.22.0
Webpack 5.105.4
TypeScript 5.8.3
Jest 30.2.0

Background

In SPFx ≤ 1.21 (Gulp toolchain), only files named *.module.scss were treated as CSS modules. Plain .scss files produced no typed declaration — attempting to import styles from './something.scss' was a TypeScript compilation error, which enforced the correct pattern.

In SPFx 1.22+ (Heft toolchain), heft-sass-plugin generates a typed IStyles declaration for all .scss and .css files by default. The TypeScript error is gone. The code compiles. The browser crashes.

Related issues:

  • #10466 — Original bug report for the same root cause filed against SPFx 1.22.0-beta.4. Microsoft confirmed reproduction and identified the cause as "the file extension filtering mechanism being inverted in Heft." The planned fix direction was documented as renaming non-module SCSS files to .global.scss. That fix was not shipped in 1.23.0 GA. This issue documents the same confirmed bug persisting in the released version, with evidence from compiled artifacts and a reproducible test suite.

Reproduction

The following test component exercises all four file-type variants. All files are under src/webparts/one22/components/.

Test files created

TestPlain.scss — plain SCSS, previously "global" pattern:

.plainClass { color: red; }
.anotherPlainClass { color: blue; }

TestModule.module.scss — explicit CSS module (worked in Gulp toolchain):

.moduleClass { color: green; }
.anotherModuleClass { color: purple; }

TestGlobal.global.scss — explicit global opt-out (new Heft convention):

.globalClass { color: orange; }

TestPlain.css — plain CSS file:

.plainCssClass { color: pink; }

Test component (CssModuleBehaviorTest.tsx)

import * as React from 'react';
import plainStyles from './TestPlain.scss';        // TS compiles — d.ts exists
import moduleStyles from './TestModule.module.scss';
import plainCssStyles from './TestPlain.css';      // TS compiles — d.ts exists
import './TestGlobal.global.scss';                 // side-effect only — no d.ts default export

export const CssModuleBehaviorTest: React.FC = () => (
  <div>
    <div className={plainStyles.plainClass}>plain .scss — plainClass</div>
    <div className={plainStyles.anotherPlainClass}>plain .scss — anotherPlainClass</div>
    <div className={moduleStyles.moduleClass}>.module.scss — moduleClass</div>
    <div className={moduleStyles.anotherModuleClass}>.module.scss — anotherModuleClass</div>
    <div className={plainCssStyles.plainCssClass}>plain .css — plainCssClass</div>
    <div className="globalClass">.global.scss — globalClass (no typed export)</div>
  </div>
);

Jest test file (CssModuleBehavior.test.ts)

import * as fs from 'fs';
import * as path from 'path';

import plainStyles from './components/TestPlain.scss';
import moduleStyles from './components/TestModule.module.scss';
import plainCssStyles from './components/TestPlain.css';
import './components/TestGlobal.global.scss';

const SASS_TS_DIR = path.resolve(
  __dirname,
  '../../../temp/sass-ts/webparts/one22/components'
);

describe('SPFx 1.23 Heft — CSS/SCSS module classification behavior', () => {

  describe('plain .scss — TestPlain.scss', () => {
    it('is importable as a typed CSS module (NEW in Heft — TS error in Gulp toolchain)', () => {
      expect(plainStyles).toBeDefined();
    });
    it('exposes typed class names via identity mock', () => {
      expect(plainStyles.plainClass).toBe('plainClass');
      expect(plainStyles.anotherPlainClass).toBe('anotherPlainClass');
    });
    it('heft-sass-plugin generates a typed d.ts with IStyles interface', () => {
      const dts = fs.readFileSync(path.join(SASS_TS_DIR, 'TestPlain.scss.d.ts'), 'utf8');
      expect(dts).toContain('IStyles');
      expect(dts).toContain('plainClass');
      expect(dts).toContain('anotherPlainClass');
    });
  });

  describe('.module.scss — TestModule.module.scss', () => {
    it('is importable as a typed CSS module (same behaviour as Gulp toolchain)', () => {
      expect(moduleStyles).toBeDefined();
    });
    it('exposes typed class names via identity mock', () => {
      expect(moduleStyles.moduleClass).toBe('moduleClass');
      expect(moduleStyles.anotherModuleClass).toBe('anotherModuleClass');
    });
    it('heft-sass-plugin generates a typed d.ts with IStyles interface', () => {
      const dts = fs.readFileSync(path.join(SASS_TS_DIR, 'TestModule.module.scss.d.ts'), 'utf8');
      expect(dts).toContain('IStyles');
      expect(dts).toContain('moduleClass');
      expect(dts).toContain('anotherModuleClass');
    });
  });

  describe('.global.scss — TestGlobal.global.scss', () => {
    it('heft-sass-plugin does NOT generate typed class names in d.ts', () => {
      const dts = fs.readFileSync(
        path.join(SASS_TS_DIR, 'TestGlobal.global.scss.d.ts'),
        'utf8'
      );
      expect(dts).not.toContain('IStyles');
      expect(dts).not.toContain('globalClass');
    });
  });

  describe('plain .css — TestPlain.css', () => {
    it('is importable as a typed CSS module', () => {
      expect(plainCssStyles).toBeDefined();
    });
    it('exposes typed class names via identity mock', () => {
      expect(plainCssStyles.plainCssClass).toBe('plainCssClass');
    });
    it('heft-sass-plugin generates a typed d.ts with IStyles interface', () => {
      const dts = fs.readFileSync(path.join(SASS_TS_DIR, 'TestPlain.css.d.ts'), 'utf8');
      expect(dts).toContain('IStyles');
      expect(dts).toContain('plainCssClass');
    });
  });

});

Test results

PASS lib-commonjs/webparts/one22/CssModuleBehavior.test.js (10 passed, 0 failed)

All 10 assertions pass. This is part of the problem — Jest uses jest-identity-mock-transform, which returns { plainClass: 'plainClass' } for every CSS import regardless of whether webpack would produce any exports. The tests are green while the runtime is broken.

Build warnings (emitted on every heft build)

Warning: export 'default' (imported as 'plainStyles') was not found in
  './TestPlain.scss' (module has no exports)

Warning: export 'default' (imported as 'plainCssStyles') was not found in
  './TestPlain.css' (module has no exports)

Webpack warns about the missing exports but the build still exits with code 0. These warnings are the only signal that something is wrong — and only if a developer reads the full build output.

Generated declarations (temp/sass-ts/)

TestPlain.scss.d.ts — generated, typed:

declare interface IStyles {
  plainClass: string;
  anotherPlainClass: string;
}
declare const styles: IStyles;
export default styles;

TestModule.module.scss.d.ts — generated, typed:

declare interface IStyles {
  moduleClass: string;
  anotherModuleClass: string;
}
declare const styles: IStyles;
export default styles;

TestGlobal.global.scss.d.ts — generated, empty (correct):

export {};

TestPlain.css.d.ts — generated, typed:

declare interface IStyles {
  plainCssClass: string;
}
declare const styles: IStyles;
export default styles;

Webpack bundle evidence

The compiled bundle (dist/one-22-web-part.js) reveals the exact runtime behaviour for each file type.

TestModule.module.scss — webpack module 829 (correct)

// CSS output: class names hashed with module scope
loadStyles(".moduleClass_b7d7ad81{color:green}.anotherModuleClass_b7d7ad81{color:purple}", true);

// ICSS exports: default export populated
__webpack_require__.d(__webpack_exports__, {
  "default": () => (__WEBPACK_DEFAULT_EXPORT__)
});
const __WEBPACK_DEFAULT_EXPORT__ = ({
  moduleClass: "moduleClass_b7d7ad81",
  anotherModuleClass: "anotherModuleClass_b7d7ad81"
});

TestPlain.scss — webpack module 220 (broken)

// CSS output: class names NOT hashed — global scope
loadStyles(".plainClass{color:red}.anotherPlainClass{color:blue}", true);

// No __webpack_require__.d call — module.exports.default is undefined

TestPlain.css — webpack module 239 (broken)

// CSS output: class names NOT hashed — global scope
loadStyles(".plainCssClass{color:pink}", true);

// No __webpack_require__.d call — module.exports.default is undefined

TestGlobal.global.scss — webpack module 540 (correct by design)

// CSS output: not hashed — intentionally global
loadStyles(".globalClass{color:orange}", true);

// No exports — correct, matches the empty d.ts

The runtime crash

The component accesses class names on module.exports.default, which is undefined for plain .scss and plain .css:

// bundle line 130 — plainStyles.default is undefined:
createElement("div", { className: _TestPlain_scss__["default"].plainClass }, ...)
//                                                           ↑ TypeError: Cannot read properties of undefined

Special case: @use inside .module.scss

When a plain .scss is composed via @use inside a .module.scss, its content is merged into the parent file before webpack processing. The parent file matches webpack's module CSS loader rule (/\.module(?:\.scss)?\.css$/i), so the plain .scss content inherits the parent module's context — class names are hashed and ICSS exports are present:

// One22.module.scss.css — TestPlain.scss content merged in via @use:
loadStyles(".plainClass_37f1a043{color:red}.anotherPlainClass_37f1a043{color:blue}...", true);
const __WEBPACK_DEFAULT_EXPORT__ = ({
  plainClass: "plainClass_37f1a043",      // accessible via One22.module.scss's export
  anotherPlainClass: "anotherPlainClass_37f1a043",
  // ...all One22.module.scss classes follow
});

Note: the hashed class names are accessible only via the parent module's export (import styles from './One22.module.scss'styles.plainClass). A direct import of TestPlain.scss still yields undefined even when that file is @use'd elsewhere.

Complete behaviour summary

File type heft-sass-plugin d.ts TypeScript import Webpack loader Runtime default export Runtime class access
plain.scss (direct TS import) IStyles with class names ✅ Compiles ❌ Global CSS loader undefined 💥 TypeError
plain.scss (via @use in .module.scss) IStyles with class names N/A — SCSS level ✅ Module CSS loader Hashed names ✅ Works
*.module.scss IStyles with class names ✅ Compiles ✅ Module CSS loader Hashed names ✅ Works
*.global.scss export {} (empty) Side-effect import only ❌ Global CSS loader None (side-effect) N/A
plain.css (direct TS import) IStyles with class names ✅ Compiles ❌ Global CSS loader undefined 💥 TypeError

Root cause

heft-sass-plugin and webpack use independent classification rules that are not aligned:

heft-sass-plugin (SassProcessor.js line 639 / sass.json):

{
  "fileExtensions": [".sass", ".scss", ".css"],
  "nonModuleFileExtensions": [".global.sass", ".global.scss", ".global.css"]
}

All .scss and .css files → generate typed IStyles d.ts (module treatment).

webpack (WebpackConfigurationGenerator.js lines 314–321):

{ use: moduleScssCssLoaders, test: /\.module(?:\.scss)?\.css$/i },   // CSS module loader
{ use: cssLoaders,           test: /(?<!\.module(?:\.scss)?)\.css$/ } // Global CSS loader

Only *.module.scss.css and *.module.css → ICSS exports. Everything else → global.

The two systems apply different criteria to the same files, with no coordination between them.

Impact on SPFx developers

Breaking change for upgrades from SPFx ≤ 1.21

In SPFx ≤ 1.21 (Gulp toolchain), import styles from './something.scss' was a TypeScript compilation error for plain .scss files — no d.ts was generated, so the import failed at compile time. This enforced the correct pattern.

In SPFx 1.22+ (Heft), the d.ts exists → TypeScript compiles → webpack warns → browser crashes. The compile-time guard is gone, replaced by a silent runtime failure.

Scenario 1 — Global override files for third-party components

Many projects use plain .scss files like fluent-overrides.scss or vendor-fixes.scss to globally override Fluent UI or PnP Controls styles. In Gulp (≤ 1.21) these were unambiguously global. In Heft 1.22+:

  • heft-sass-plugin generates typed d.ts → TypeScript allows import styles from './fluent-overrides.scss'
  • Webpack routes the file through the global CSS loader → class names are not hashed, styles leak globally
  • Developers who follow the TypeScript types get undefined at runtime; developers who ignore the types get the same global behaviour as before — but with no way to distinguish the two patterns by looking at the code

Scenario 2 — Direct import of plain .scss for class name access

Any component that does:

import styles from './MyStyles.scss';    // TS compiles since Heft 1.22
// ...
<div className={styles.myClass}>        // TypeError at runtime — styles.default is undefined

This pattern was a compile error in Gulp toolchain and is now a runtime TypeError in Heft.

Scenario 3 — Plain .css files used as CSS modules

heft-sass-plugin generates IStyles d.ts for plain .css files (same classification as plain .scss). Webpack routes them through the global CSS loader. Identical runtime failure — import styles from './something.css' compiles and crashes.

Scenario 4 — No automated migration path

There is no tool, warning, or documentation that identifies which files need to be renamed to .global.scss when upgrading from Gulp to Heft. The required migration is:

  • Any plain .scss used for global styles → rename to .global.scss
  • Any plain .css used for global styles → rename to .global.css
  • Remove any import styles from './something.scss' that targets a file not named *.module.scss

A project with dozens of plain .scss files and a large component tree can have this regression in production before it is detected.

Expected behaviour

One of the following should be true — currently neither is:

  1. heft-sass-plugin should match webpack's classification: Only generate IStyles d.ts for *.module.scss files. Plain .scss files should receive the same empty export {} treatment as .global.scss. This restores the Gulp toolchain contract.

  2. Webpack should match heft-sass-plugin's classification: Route all .scss and .css files through the module CSS loader (with ICSS exports and hashed class names). This would make plain .scss behave as true CSS modules at runtime.

  3. Documentation and a migration guide: If the current two-layer classification is intentional design, it must be documented explicitly, with a migration checklist for projects upgrading from SPFx ≤ 1.21, and the webpack build warnings should be promoted to errors for affected imports.

Steps to reproduce

  1. Create a new SPFx 1.23.0 project (yo @microsoft/sharepoint)
  2. Create src/webparts/<name>/components/TestPlain.scss with any class
  3. In a component, add import styles from './TestPlain.scss';
  4. Run heft build — TypeScript compiles without error, webpack emits a warning
  5. Serve the web part — accessing styles.myClass throws TypeError in the browser

Metadata

Metadata

Assignees

Labels

Needs: Triage 🔍Awaiting categorization and initial review.area:spfxCategory: SharePoint Framework (not extensions related)type:bug-confirmedConfirmed bug, not working as designed / expected.type:bug-suspectedSuspected bug (not working as designed/expected). See “type:bug-confirmed” for confirmed bugs.

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions