Skip to content

Commit 389eb2b

Browse files
iclantonclaude
andauthored
[heft-sass-plugin] Fix JS shims and .d.ts for .module.scss files with only :global styles (#5766)
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 <noreply@anthropic.com>
1 parent e540e9b commit 389eb2b

5 files changed

Lines changed: 178 additions & 29 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "Fix generated JS shims and `.d.ts` for `.module.scss` files that contain only `:global` styles and have no local CSS class exports",
5+
"type": "patch",
6+
"packageName": "@rushstack/heft-sass-plugin"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-sass-plugin",
10+
"email": "iclanton@users.noreply.github.com"
11+
}

heft-plugins/heft-sass-plugin/src/SassProcessor.ts

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,11 @@ export class SassProcessor {
795795

796796
const relativeFilePath: string = path.relative(srcFolder, sourceFilePath);
797797

798-
const dtsContent: string = this._createDTS(moduleMap);
798+
// A module file with no local class exports (e.g. only :global styles) has no
799+
// default export at runtime, so treat it as a side-effect-only import just like
800+
// a non-module file.
801+
const hasModuleExports: boolean | undefined = moduleMap && Object.keys(moduleMap).length > 0;
802+
const dtsContent: string = createDTS(moduleMap, exportAsDefault, hasModuleExports);
799803

800804
const writeFileOptions: IFileSystemWriteFileOptions = {
801805
ensureFolderExists: true
@@ -840,48 +844,58 @@ export class SassProcessor {
840844
const jsShimContent: string = generateJsShimContent(
841845
shimModuleFormat,
842846
cssPathFromJs,
843-
record.isModule
847+
hasModuleExports
844848
);
845849
await FileSystem.writeFileAsync(jsFilePath, jsShimContent, writeFileOptions);
846850
}
847851
}
848852
}
849853
}
854+
}
850855

851-
private _createDTS(moduleMap: JsonObject | undefined): string {
856+
function createDTS(
857+
moduleMap: JsonObject | undefined,
858+
exportAsDefault: boolean,
859+
hasModuleExports: boolean | undefined
860+
): string;
861+
function createDTS(moduleMap: JsonObject, exportAsDefault: boolean, hasModuleExports: true): string;
862+
function createDTS(
863+
moduleMap: JsonObject | undefined,
864+
exportAsDefault: boolean,
865+
hasModuleExports: boolean | undefined
866+
): string {
867+
if (hasModuleExports) {
852868
// Create a source file.
853869
const source: string[] = [];
854870

855-
if (moduleMap) {
856-
if (this._options.exportAsDefault) {
857-
source.push(`declare interface IStyles {`);
858-
for (const className of Object.keys(moduleMap)) {
859-
const safeClassName: string = SIMPLE_IDENTIFIER_REGEX.test(className)
860-
? className
861-
: JSON.stringify(className);
862-
// Quote and escape class names as needed.
863-
source.push(` ${safeClassName}: string;`);
864-
}
865-
source.push(`}`);
866-
source.push(`declare const styles: IStyles;`);
867-
source.push(`export default styles;`);
868-
} else {
869-
for (const className of Object.keys(moduleMap)) {
870-
if (!SIMPLE_IDENTIFIER_REGEX.test(className)) {
871-
throw new Error(
872-
`Class name "${className}" is not a valid identifier and may only be exported using "exportAsDefault: true"`
873-
);
874-
}
875-
source.push(`export const ${className}: string;`);
876-
}
871+
if (exportAsDefault) {
872+
source.push(`declare interface IStyles {`);
873+
for (const className of Object.keys(moduleMap)) {
874+
const safeClassName: string = SIMPLE_IDENTIFIER_REGEX.test(className)
875+
? className
876+
: JSON.stringify(className);
877+
// Quote and escape class names as needed.
878+
source.push(` ${safeClassName}: string;`);
877879
}
878-
}
879880

880-
if (source.length === 0 || !moduleMap) {
881-
return `export {};`;
881+
source.push(`}`);
882+
source.push(`declare const styles: IStyles;`);
883+
source.push(`export default styles;`);
884+
} else {
885+
for (const className of Object.keys(moduleMap)) {
886+
if (!SIMPLE_IDENTIFIER_REGEX.test(className)) {
887+
throw new Error(
888+
`Class name "${className}" is not a valid identifier and may only be exported using "exportAsDefault: true"`
889+
);
890+
}
891+
892+
source.push(`export const ${className}: string;`);
893+
}
882894
}
883895

884896
return source.join('\n');
897+
} else {
898+
return `export {};`;
885899
}
886900
}
887901

@@ -1021,7 +1035,7 @@ function determineSyntaxFromFilePath(filePath: string): Syntax {
10211035
function generateJsShimContent(
10221036
format: 'commonjs' | 'esnext',
10231037
relativePathToCss: string,
1024-
isModule: boolean
1038+
isModule: boolean | undefined
10251039
): string {
10261040
const pathString: string = JSON.stringify(relativePathToCss);
10271041
switch (format) {

heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,41 @@ describe(SassProcessor.name, () => {
370370
});
371371
});
372372

373+
describe('global-only.module.scss (module file with only :global styles)', () => {
374+
it('emits export {}; in the .d.ts when all styles are :global', async () => {
375+
const { processor } = createProcessor(terminalProvider);
376+
await compileFixtureAsync(processor, 'global-only.module.scss');
377+
const dts: string = getDtsOutput('global-only.module.scss');
378+
expect(dts).toBe('export {};');
379+
});
380+
381+
it('emits a side-effect ESM shim (no default re-export) when all styles are :global', async () => {
382+
const { processor } = createProcessor(terminalProvider, {
383+
cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'esnext' }]
384+
});
385+
await compileFixtureAsync(processor, 'global-only.module.scss');
386+
const shim: string = getJsShimOutput('global-only.module.scss');
387+
expect(shim).toBe(`import "./global-only.module.css";export {};`);
388+
});
389+
390+
it('emits a side-effect CJS shim (no module.exports assignment) when all styles are :global', async () => {
391+
const { processor } = createProcessor(terminalProvider, {
392+
cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }]
393+
});
394+
await compileFixtureAsync(processor, 'global-only.module.scss');
395+
const shim: string = getJsShimOutput('global-only.module.scss');
396+
expect(shim).toBe(`require("./global-only.module.css");`);
397+
});
398+
399+
it('emits compiled CSS with the :global styles applied', async () => {
400+
const { processor } = createProcessor(terminalProvider);
401+
await compileFixtureAsync(processor, 'global-only.module.scss');
402+
const css: string = getCssOutput('global-only.module.scss');
403+
expect(css).toContain('.ms-Nav-group');
404+
expect(css).toContain('.ms-Nav-link');
405+
});
406+
});
407+
373408
describe('non-module (global) files', () => {
374409
it('emits plain compiled CSS for a .global.scss file', async () => {
375410
const { processor } = createProcessor(terminalProvider, {

heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,84 @@ export default styles;",
585585
}
586586
`;
587587

588+
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`] = `
589+
Array [
590+
"[verbose] Checking for changes to 1 files...[n]",
591+
"[ log] Compiling 1 files...[n]",
592+
]
593+
`;
594+
595+
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`] = `
596+
Map {
597+
"/fake/output/dts/global-only.module.scss.d.ts" => "export {};",
598+
"/fake/output/css/global-only.module.css" => ".ms-Nav-group {
599+
overflow: hidden;
600+
}
601+
.ms-Nav-link {
602+
height: 30px;
603+
}",
604+
"/fake/output/css/global-only.module.scss.js" => "require(\\"./global-only.module.css\\");",
605+
}
606+
`;
607+
608+
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`] = `
609+
Array [
610+
"[verbose] Checking for changes to 1 files...[n]",
611+
"[ log] Compiling 1 files...[n]",
612+
]
613+
`;
614+
615+
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`] = `
616+
Map {
617+
"/fake/output/dts/global-only.module.scss.d.ts" => "export {};",
618+
"/fake/output/css/global-only.module.css" => ".ms-Nav-group {
619+
overflow: hidden;
620+
}
621+
.ms-Nav-link {
622+
height: 30px;
623+
}",
624+
"/fake/output/css/global-only.module.scss.js" => "import \\"./global-only.module.css\\";export {};",
625+
}
626+
`;
627+
628+
exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits compiled CSS with the :global styles applied: terminal-output 1`] = `
629+
Array [
630+
"[verbose] Checking for changes to 1 files...[n]",
631+
"[ log] Compiling 1 files...[n]",
632+
]
633+
`;
634+
635+
exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits compiled CSS with the :global styles applied: written-files 1`] = `
636+
Map {
637+
"/fake/output/dts/global-only.module.scss.d.ts" => "export {};",
638+
"/fake/output/css/global-only.module.css" => ".ms-Nav-group {
639+
overflow: hidden;
640+
}
641+
.ms-Nav-link {
642+
height: 30px;
643+
}",
644+
}
645+
`;
646+
647+
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`] = `
648+
Array [
649+
"[verbose] Checking for changes to 1 files...[n]",
650+
"[ log] Compiling 1 files...[n]",
651+
]
652+
`;
653+
654+
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`] = `
655+
Map {
656+
"/fake/output/dts/global-only.module.scss.d.ts" => "export {};",
657+
"/fake/output/css/global-only.module.css" => ".ms-Nav-group {
658+
overflow: hidden;
659+
}
660+
.ms-Nav-link {
661+
height: 30px;
662+
}",
663+
}
664+
`;
665+
588666
exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: terminal-output 1`] = `
589667
Array [
590668
"[verbose] Checking for changes to 1 files...[n]",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// A module SCSS file that contains only :global styles and no local CSS module class exports.
2+
// This pattern is used for applying global overrides from a file named .module.scss.
3+
:global {
4+
.ms-Nav-group {
5+
overflow: hidden;
6+
}
7+
8+
.ms-Nav-link {
9+
height: 30px;
10+
}
11+
}

0 commit comments

Comments
 (0)