From 2f5c38ed3acb750ff594f220ed34c49e013621da Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 10 Apr 2026 15:52:23 -0700 Subject: [PATCH] [heft-sass-plugin] Fix JS shims and .d.ts for .module.scss files with only :global styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a .module.scss file contains only :global selectors (no local CSS class names), postcss-modules produces an empty moduleMap. The plugin was still generating `export { default } from "./file.css"` shims and `export default styles` .d.ts declarations for these files, but the downstream CSS loader emits no default export when there are no local classes, causing webpack to warn "export 'default' was not found (module has no exports)". Fix: treat an empty moduleMap the same as a non-module file — emit side-effect-only shims (`import "./file.css"; export {}` / `require(...)`) and `export {};` in the .d.ts. Adds a global-only.module.scss fixture and four new tests covering this pattern. Co-Authored-By: Claude Sonnet 4.6 --- ...empty-module-exports_2026-04-10-22-33.json | 11 +++ .../heft-sass-plugin/src/SassProcessor.ts | 72 ++++++++++------- .../src/test/SassProcessor.test.ts | 35 +++++++++ .../__snapshots__/SassProcessor.test.ts.snap | 78 +++++++++++++++++++ .../src/test/fixtures/global-only.module.scss | 11 +++ 5 files changed, 178 insertions(+), 29 deletions(-) create mode 100644 common/changes/@rushstack/heft-sass-plugin/fix-empty-module-exports_2026-04-10-22-33.json create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/global-only.module.scss diff --git a/common/changes/@rushstack/heft-sass-plugin/fix-empty-module-exports_2026-04-10-22-33.json b/common/changes/@rushstack/heft-sass-plugin/fix-empty-module-exports_2026-04-10-22-33.json new file mode 100644 index 0000000000..15597e2d5c --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/fix-empty-module-exports_2026-04-10-22-33.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Fix generated JS shims and `.d.ts` for `.module.scss` files that contain only `:global` styles and have no local CSS class exports", + "type": "patch", + "packageName": "@rushstack/heft-sass-plugin" + } + ], + "packageName": "@rushstack/heft-sass-plugin", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 9bddcd03f2..b5cace7c38 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -795,7 +795,11 @@ export class SassProcessor { const relativeFilePath: string = path.relative(srcFolder, sourceFilePath); - const dtsContent: string = this._createDTS(moduleMap); + // A module file with no local class exports (e.g. only :global styles) has no + // default export at runtime, so treat it as a side-effect-only import just like + // a non-module file. + const hasModuleExports: boolean | undefined = moduleMap && Object.keys(moduleMap).length > 0; + const dtsContent: string = createDTS(moduleMap, exportAsDefault, hasModuleExports); const writeFileOptions: IFileSystemWriteFileOptions = { ensureFolderExists: true @@ -840,48 +844,58 @@ export class SassProcessor { const jsShimContent: string = generateJsShimContent( shimModuleFormat, cssPathFromJs, - record.isModule + hasModuleExports ); await FileSystem.writeFileAsync(jsFilePath, jsShimContent, writeFileOptions); } } } } +} - private _createDTS(moduleMap: JsonObject | undefined): string { +function createDTS( + moduleMap: JsonObject | undefined, + exportAsDefault: boolean, + hasModuleExports: boolean | undefined +): string; +function createDTS(moduleMap: JsonObject, exportAsDefault: boolean, hasModuleExports: true): string; +function createDTS( + moduleMap: JsonObject | undefined, + exportAsDefault: boolean, + hasModuleExports: boolean | undefined +): string { + if (hasModuleExports) { // Create a source file. const source: string[] = []; - if (moduleMap) { - if (this._options.exportAsDefault) { - source.push(`declare interface IStyles {`); - for (const className of Object.keys(moduleMap)) { - const safeClassName: string = SIMPLE_IDENTIFIER_REGEX.test(className) - ? className - : JSON.stringify(className); - // Quote and escape class names as needed. - source.push(` ${safeClassName}: string;`); - } - source.push(`}`); - source.push(`declare const styles: IStyles;`); - source.push(`export default styles;`); - } else { - for (const className of Object.keys(moduleMap)) { - if (!SIMPLE_IDENTIFIER_REGEX.test(className)) { - throw new Error( - `Class name "${className}" is not a valid identifier and may only be exported using "exportAsDefault: true"` - ); - } - source.push(`export const ${className}: string;`); - } + if (exportAsDefault) { + source.push(`declare interface IStyles {`); + for (const className of Object.keys(moduleMap)) { + const safeClassName: string = SIMPLE_IDENTIFIER_REGEX.test(className) + ? className + : JSON.stringify(className); + // Quote and escape class names as needed. + source.push(` ${safeClassName}: string;`); } - } - if (source.length === 0 || !moduleMap) { - return `export {};`; + source.push(`}`); + source.push(`declare const styles: IStyles;`); + source.push(`export default styles;`); + } else { + for (const className of Object.keys(moduleMap)) { + if (!SIMPLE_IDENTIFIER_REGEX.test(className)) { + throw new Error( + `Class name "${className}" is not a valid identifier and may only be exported using "exportAsDefault: true"` + ); + } + + source.push(`export const ${className}: string;`); + } } return source.join('\n'); + } else { + return `export {};`; } } @@ -1021,7 +1035,7 @@ function determineSyntaxFromFilePath(filePath: string): Syntax { function generateJsShimContent( format: 'commonjs' | 'esnext', relativePathToCss: string, - isModule: boolean + isModule: boolean | undefined ): string { const pathString: string = JSON.stringify(relativePathToCss); switch (format) { diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 6f9ca80112..940a76031a 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -370,6 +370,41 @@ describe(SassProcessor.name, () => { }); }); + describe('global-only.module.scss (module file with only :global styles)', () => { + it('emits export {}; in the .d.ts when all styles are :global', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'global-only.module.scss'); + const dts: string = getDtsOutput('global-only.module.scss'); + expect(dts).toBe('export {};'); + }); + + it('emits a side-effect ESM shim (no default re-export) when all styles are :global', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'esnext' }] + }); + await compileFixtureAsync(processor, 'global-only.module.scss'); + const shim: string = getJsShimOutput('global-only.module.scss'); + expect(shim).toBe(`import "./global-only.module.css";export {};`); + }); + + it('emits a side-effect CJS shim (no module.exports assignment) when all styles are :global', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }] + }); + await compileFixtureAsync(processor, 'global-only.module.scss'); + const shim: string = getJsShimOutput('global-only.module.scss'); + expect(shim).toBe(`require("./global-only.module.css");`); + }); + + it('emits compiled CSS with the :global styles applied', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'global-only.module.scss'); + const css: string = getCssOutput('global-only.module.scss'); + expect(css).toContain('.ms-Nav-group'); + expect(css).toContain('.ms-Nav-link'); + }); + }); + describe('non-module (global) files', () => { it('emits plain compiled CSS for a .global.scss file', async () => { const { processor } = createProcessor(terminalProvider, { diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index 8ef7e174a5..0c9223d5aa 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -585,6 +585,84 @@ export default styles;", } `; +exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits a side-effect CJS shim (no module.exports assignment) when all styles are :global: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits a side-effect CJS shim (no module.exports assignment) when all styles are :global: written-files 1`] = ` +Map { + "/fake/output/dts/global-only.module.scss.d.ts" => "export {};", + "/fake/output/css/global-only.module.css" => ".ms-Nav-group { + overflow: hidden; +} +.ms-Nav-link { + height: 30px; +}", + "/fake/output/css/global-only.module.scss.js" => "require(\\"./global-only.module.css\\");", +} +`; + +exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits a side-effect ESM shim (no default re-export) when all styles are :global: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits a side-effect ESM shim (no default re-export) when all styles are :global: written-files 1`] = ` +Map { + "/fake/output/dts/global-only.module.scss.d.ts" => "export {};", + "/fake/output/css/global-only.module.css" => ".ms-Nav-group { + overflow: hidden; +} +.ms-Nav-link { + height: 30px; +}", + "/fake/output/css/global-only.module.scss.js" => "import \\"./global-only.module.css\\";export {};", +} +`; + +exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits compiled CSS with the :global styles applied: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits compiled CSS with the :global styles applied: written-files 1`] = ` +Map { + "/fake/output/dts/global-only.module.scss.d.ts" => "export {};", + "/fake/output/css/global-only.module.css" => ".ms-Nav-group { + overflow: hidden; +} +.ms-Nav-link { + height: 30px; +}", +} +`; + +exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits export {}; in the .d.ts when all styles are :global: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits export {}; in the .d.ts when all styles are :global: written-files 1`] = ` +Map { + "/fake/output/dts/global-only.module.scss.d.ts" => "export {};", + "/fake/output/css/global-only.module.css" => ".ms-Nav-group { + overflow: hidden; +} +.ms-Nav-link { + height: 30px; +}", +} +`; + exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/global-only.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/global-only.module.scss new file mode 100644 index 0000000000..529102ce04 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/global-only.module.scss @@ -0,0 +1,11 @@ +// A module SCSS file that contains only :global styles and no local CSS module class exports. +// This pattern is used for applying global overrides from a file named .module.scss. +:global { + .ms-Nav-group { + overflow: hidden; + } + + .ms-Nav-link { + height: 30px; + } +}