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; + } +}