[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):
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:
-
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.
-
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.
-
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
- Create a new SPFx 1.23.0 project (
yo @microsoft/sharepoint)
- Create
src/webparts/<name>/components/TestPlain.scss with any class
- In a component, add
import styles from './TestPlain.scss';
- Run
heft build — TypeScript compiles without error, webpack emits a warning
- Serve the web part — accessing
styles.myClass throws TypeError in the browser
[SPFx 1.22+] Undocumented breaking change: plain
.scssand.cssfiles generate typed CSS module declarations but webpack processes them as global CSSSummary
In SPFx 1.22+ (Heft toolchain),
heft-sass-plugingenerates typed TypeScript declarations (IStyles) for all.scssand.cssfiles, 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 throwsTypeErrorat 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
@rushstack/heft-sass-plugin@microsoft/sp-css-loader@microsoft/spfx-heft-pluginsBackground
In SPFx ≤ 1.21 (Gulp toolchain), only files named
*.module.scsswere treated as CSS modules. Plain.scssfiles produced no typed declaration — attempting toimport styles from './something.scss'was a TypeScript compilation error, which enforced the correct pattern.In SPFx 1.22+ (Heft toolchain),
heft-sass-plugingenerates a typedIStylesdeclaration for all.scssand.cssfiles by default. The TypeScript error is gone. The code compiles. The browser crashes.Related issues:
.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:TestModule.module.scss— explicit CSS module (worked in Gulp toolchain):TestGlobal.global.scss— explicit global opt-out (new Heft convention):TestPlain.css— plain CSS file:Test component (
CssModuleBehaviorTest.tsx)Jest test file (
CssModuleBehavior.test.ts)Test results
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)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:TestModule.module.scss.d.ts— generated, typed:TestGlobal.global.scss.d.ts— generated, empty (correct):TestPlain.css.d.ts— generated, typed: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)TestPlain.scss— webpack module 220 (broken)TestPlain.css— webpack module 239 (broken)TestGlobal.global.scss— webpack module 540 (correct by design)The runtime crash
The component accesses class names on
module.exports.default, which isundefinedfor plain.scssand plain.css:Special case:
@useinside.module.scssWhen a plain
.scssis composed via@useinside 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.scsscontent inherits the parent module's context — class names are hashed and ICSS exports are present: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 ofTestPlain.scssstill yieldsundefinedeven when that file is@use'd elsewhere.Complete behaviour summary
defaultexportplain.scss(direct TS import)IStyleswith class namesundefinedTypeErrorplain.scss(via@usein.module.scss)IStyleswith class names*.module.scssIStyleswith class names*.global.scssexport {}(empty)plain.css(direct TS import)IStyleswith class namesundefinedTypeErrorRoot cause
heft-sass-pluginand webpack use independent classification rules that are not aligned:heft-sass-plugin (
SassProcessor.jsline 639 /sass.json):{ "fileExtensions": [".sass", ".scss", ".css"], "nonModuleFileExtensions": [".global.sass", ".global.scss", ".global.css"] }All
.scssand.cssfiles → generate typedIStylesd.ts (module treatment).webpack (
WebpackConfigurationGenerator.jslines 314–321):Only
*.module.scss.cssand*.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.scssfiles — 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
.scssfiles likefluent-overrides.scssorvendor-fixes.scssto globally override Fluent UI or PnP Controls styles. In Gulp (≤ 1.21) these were unambiguously global. In Heft 1.22+:import styles from './fluent-overrides.scss'undefinedat 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 codeScenario 2 — Direct import of plain
.scssfor class name accessAny component that does:
This pattern was a compile error in Gulp toolchain and is now a runtime TypeError in Heft.
Scenario 3 — Plain
.cssfiles used as CSS modulesheft-sass-plugingeneratesIStylesd.ts for plain.cssfiles (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.scsswhen upgrading from Gulp to Heft. The required migration is:.scssused for global styles → rename to.global.scss.cssused for global styles → rename to.global.cssimport styles from './something.scss'that targets a file not named*.module.scssA project with dozens of plain
.scssfiles 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:
heft-sass-plugin should match webpack's classification: Only generate
IStylesd.ts for*.module.scssfiles. Plain.scssfiles should receive the same emptyexport {}treatment as.global.scss. This restores the Gulp toolchain contract.Webpack should match heft-sass-plugin's classification: Route all
.scssand.cssfiles through the module CSS loader (with ICSS exports and hashed class names). This would make plain.scssbehave as true CSS modules at runtime.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
yo @microsoft/sharepoint)src/webparts/<name>/components/TestPlain.scsswith any classimport styles from './TestPlain.scss';heft build— TypeScript compiles without error, webpack emits a warningstyles.myClassthrowsTypeErrorin the browser