Skip to content

feat: add CSS source map support - #6969

Open
Elements2720 wants to merge 5 commits into
stenciljs:mainfrom
Elements2720:feat/css-source-maps
Open

Elements2720 wants to merge 5 commits into
stenciljs:mainfrom
Elements2720:feat/css-source-maps

Conversation

@Elements2720

@Elements2720 Elements2720 commented Oct 20, 2026 •

Copy link
Copy Markdown

Summary

  • add independent CSS/JavaScript source-map configuration while preserving legacy boolean and dev JavaScript behavior
  • propagate CSS maps through plugin transforms, PostCSS/autoprefixing, minification, CSS-to-ESM, component metadata, global styles, and runtime/HMR injection
  • preserve maps through cached global-style optimization and constructable stylesheet registration
  • expose maps for native/custom-element default styles and direct style imports
  • chain plugin maps, emit generated mappings on their actual output lines, and compose concatenated style maps into one index map
  • add focused regression coverage for configuration, transforms, output paths, modes, runtime styles, and native styles

Fixes #6964

Verification

  • npm run build -- --ci passed
  • npm run tsc.prod passed
  • Targeted ESLint and Prettier checks passed
  • Full unit suite: 254 suites passed, 4,185 tests passed, 17 skipped, 22 snapshots passed

One unrelated existing full-suite failure remains in src/compiler/output-targets/dist-lazy/test/generate-lazy-module.spec.ts (direct-dependency sort expectation).

@Elements2720
Elements2720 requested a review from a team as a code owner October 20, 2026 03:51

@stephan-dum stephan-dum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you so much for implementing it that fast <3

i added some suggestions and feedback

already looking forward to use it!

@@ -0,0 +1,56 @@
jest.mock(
'../../sys/node/autoprefixer.js',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i was already wondering when reading the source code inside src/compiler/optimize/autoprefixer.ts
this import is non existing for me.

const getProcessor = (autoprefixerOpts: d.AutoprefixerOptions): CssProcessor => {
  const { postcss, autoprefixer } = require('../sys/node/autoprefixer.js'); // <--- non existing
  if (!cssProcessor) {
    cssProcessor = postcss([autoprefixer(autoprefixerOpts)]);
  }
  return cssProcessor;
};

it does not surface because src/compiler/optimize/optimize-css.ts checks for errors and returns the result as fallback

 result = await autoprefixCss(inputOpts.input, inputOpts.autoprefixer ?? null);
 if (hasError(result.diagnostics)) {
    return result;
 }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module is generated during the compiler build: src/sys/node/bundles/autoprefixer.js is bundled as sys/node/autoprefixer.js. The virtual Jest mock is intentional because unit tests run before that build artifact exists. Added a test comment documenting this.

Comment thread src/compiler/optimize/minify-css.ts Outdated
return generator.toJSON() as d.SourceMap;
};

const getLineAndColumn = (text: string, index: number) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calling this repetitive can be quite expensive, we could consider a binary search instead, maybe something into this direction: https://github.com/cenfun/monocart-locator/blob/c227b9574146ee0b6a6ee7355df2ac747b91b979/lib/line-parser.js#L72

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Source-map line/column lookup now precomputes line starts and uses binary search instead of rescanning the prefix for every token.

file: compileOpts.file,
style: compileOpts.style as any,
styleImportData: compileOpts.styleImportData as any,
sourceMapCss: compileOpts.sourceMap !== false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to check if sourceMap.css is set as well?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the legacy TranspileOptions path, whose sourceMap option is boolean or inline; it does not use the validated build sourceMap.css object. sourceMapCss is therefore derived from compileOpts.sourceMap !== false here.

return withoutSourceMap;
}
const sourceMaps = Array.isArray(sourceMap) ? sourceMap : [sourceMap];
return `${withoutSourceMap}${sourceMaps.map((map) => `\n/*# sourceMappingURL=${map} */`).join('')}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applying multiple source maps to the same file is not ideal

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. HMR now selects the source map matching each style element, so it does not append multiple maps to one element.

};

const appendStyleSourceMap = (styleText: string, sourceMap?: string | string[]) => {
const withoutSourceMap = styleText.replace(/\s*\/\*[#@]\s*sourceMappingURL=[^*]*\*\/\s*$/i, '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would prefer to remove any existing source map already a build time

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HMR path strips any existing trailing source-map annotation before applying the replacement map. Runtime registration preserves an existing annotation only when no replacement map is supplied.

Comment thread src/compiler/style/global-styles.ts Outdated
if (!sourceMap) {
return css;
}
const sourceMapUrl = `data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(sourceMap)).toString('base64')}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would prefer external source maps if possible as this will increase bundle size

Comment thread src/compiler/style/css-to-esm.ts Outdated
return results;
};

const sourceMapToDataUrl = (sourceMap: d.SourceMap) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nice to have them as external source maps

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For dist-global-styles, implemented external .map emission and a relative CSS reference. CSS embedded in component JavaScript has no standalone CSS asset to reference, so those maps remain attached per style entry.

@stephan-dum stephan-dum Oct 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For production bundles this is still not ideal as it will bloat the bundle size, the specs are marking this field as optional:

The file field is an optional name of the generated code that this source map is associated with. It's not specified if this can be an URL, relative path name, or just a base name. Source map generators may choose the appropriate interpretation for their contexts of use.

so it should be possible to place the file next to the js bundle (ie entryX.css.map) as long as the source map reference in the css output is correct this should not be a problem.

Alternatively we could also put them next to the collection css output but i am not sure how they are consumed and if the reference would break if processed further.

// do this BEFORE transformations on css files
if (shouldParseCssDocs && cmp != null) {
cmp.styleDocs = cmp.styleDocs || [];
const cssParseResults = await parseCssImports(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would expect that this is also included into the source map

...getDependencySubset(cssParseResults.imports, transformResults.dependencies),
);
} else {
const cssParseResults = await parseCssImports(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would expect that this is also included in the source map

// concat all css @imports into one file
// when the entry file is a .css file (not .scss)
// do this BEFORE transformations on css files
const cssParseResults = await parseCssImports(config, compilerCtx, buildCtx, id, id, transformResults.code);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would expect that this is also included in the source map

// the output but only updated it to use url() instead. Let's go ahead and concat
// the url() css files into one file like we did for raw .css files. Do this
// AFTER transformations on non-css files
const cssParseResults = await parseCssImports(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would expect that this is also included in the source map,
i also wonder why not do this only at the end?

Comment thread src/runtime/initialize-component.ts Outdated
Comment on lines 187 to 189
@@ -185,7 +189,7 @@ export const initializeComponent = async (
style = expandPartSelectors(style);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess that will change the style and our mapping is destroyed 🤔
i wonder if its possible to do that already at build time and generate a source map for it

Comment thread src/runtime/styles.ts
.join('')}`;
};

const createStyleSourceMapIndex = (sourceMaps: d.StyleSourceMap[]) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will only work with inline source maps, can't we use individual styles for each styleUrl entry?

i am asking because we really want the source map also in our production build so that consumers of our component library see whats going on to ease reporting issues back to us

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented separate runtime entries for each styleUrl, with matching per-entry source maps. This avoids relying on a single concatenated inline map in production component builds.

@stephan-dum

Copy link
Copy Markdown

@Elements2720 let me know if you need any support, i could write an e2e test for example if you like

@Elements2720

Copy link
Copy Markdown
Author

@stephan-dum check the changes, i have noted your review on the code.


if (isString(input.tag) && input.encapsulation === 'scoped') {
const scopeId = getScopeId(input.tag, input.mode);
results.styleText = scopeCss(results.styleText, scopeId, false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will also alter the style and needs to be included in the source map

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The pre-scope CSS is now used as the source-map input, and the scoped output is mapped back through the plugin map before optimization. Added a regression test for scoped source-map composition.

results.styleText = scopeCss(results.styleText, scopeId, false);
}

const cssImports = getCssToEsmImports(varNames, results.styleText, input.file, input.mode);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am a bit confused here:
in plugin.ts we are calling parseCssImports, so there should not be any imports anymore
is this a left over for the legacy transpile function?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The production bundle path already resolves CSS imports in runPluginTransformsEsmImports. This parser remains as the fallback for the public transpile API, which still receives raw CSS and needs to generate CSS module imports.

Comment thread src/compiler/style/css-to-esm.ts Outdated
const createStyleSourceMapExpression = (input: d.TransformCssToEsmInput, results: d.TransformCssToEsmOutput) => {
const parentSourceMap = JSON.stringify(results.styleSourceMap) ?? 'undefined';
if (input.sourceMap && results.imports.length > 0) {
return `[].concat(${results.imports.map((cssImport) => `${cssImport.varName}SourceMap`).join(', ')}, ${parentSourceMap})`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this will work: as mentioned before this functionality is just used by transpile which is only used in test code, this will never be passed back to to loaders and even then if could not resolve because its lacks the ?tag query parameter

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the imported source-map expression. CSS imports are legacy transpile behavior here; production component CSS imports are resolved before this transform, and the transpile path now preserves its existing concatenated-style behavior with one map for the transformed CSS.

return css;
}
const sourceMapUrl = sourceMapToDataUrl(sourceMap);
return `${css}\n/*# sourceMappingURL=${sourceMapUrl} */`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is still an inline source map it would be nice to write it to disc and just use the reference

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented for file-based global styles. dist-global-styles now writes a relative sourceMappingURL reference in the CSS and emits the matching .map file beside it. Component styles embedded in JS remain per-style entries because there is no standalone CSS asset to reference.


addNativeStaticStyle(classMembers, component, buildCtx);

expect(classMembers).toHaveLength(2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this assertion could be more precise and descriptive

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the assertion to verify both generated accessors and their names: style first, sourceMap second.

cmp.styles.forEach((style) => {
if (Array.isArray(style.externalStyles) && style.externalStyles.length > 0) {
if (typeof style.styleIdentifier !== 'string') {
createStyleIdentifier(cmp, style, { separateStyles: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this can even happen because while generating the style this is already called which is a prerequisite for this function to run

this can produce side effects as style.styleIdentifier is altered each time the function is called and the return value is also not used

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the fallback call and its side effect. Source-map identifiers are now consumed only after style generation has initialized them.

Comment on lines +315 to +318
const firstNonImportIndex = tsSourceFile.statements.findIndex((statement) => !ts.isImportDeclaration(statement));
const importStatements =
firstNonImportIndex === -1 ? tsSourceFile.statements : tsSourceFile.statements.slice(0, firstNonImportIndex);
const nonImportStatements = firstNonImportIndex === -1 ? [] : tsSourceFile.statements.slice(firstNonImportIndex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a comment would be nice to explain why the first non import statement it the cutoff point

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment explaining that the first non-import statement is the cutoff so generated source-map declarations stay after imports and do not execute before their style bindings.

Comment thread src/runtime/styles.ts

const flattenStyleText = (cssText: StyleText): string[] => {
if (Array.isArray(cssText)) {
return cssText.reduce<string[]>((styles, text) => styles.concat(flattenStyleText(text)), []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we also use cssText.flat()?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the recursive flattening intentionally because the runtime still supports optional ES5 builds, where Array.prototype.flat is not guaranteed. Added a comment documenting that compatibility choice.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would expect the bundler to handle the target, i you need to think about every es5 feature now spread and so on will also not work

Comment thread src/runtime/styles.ts Outdated
const currentStyleSheets = styleSheets.map((styleSheet) => {
let stylesheet: CSSStyleSheet;
if (styleSheet.constructor === currentWindow.CSSStyleSheet) {
stylesheet = styleSheet;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider renaming one of those

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the source and destination stylesheet variables to sourceStyleSheet and currentStyleSheet.

@stephan-dum

Copy link
Copy Markdown

thanks for all the effort you put into this, already look great 🚀
unfortunately i can not resolve threads :-( so i will summarize it here.

the only missing points from my side are:

  1. try to externalize the source map to allow production builds with normal bundle size (see my comment)
  2. cover all remaining css transforms (parseCssImports in plugin.ts)
  3. handle runtime expandPartSelectors and scopeCSS (not sure if its possible)

@stephan-dum

stephan-dum commented Oct 23, 2026 •

Copy link
Copy Markdown

@Elements2720 i investigated a bit the scopeCss and expandPartSelectors problem as i think this is the main blocker at the moment:

scopeCss is already executed at compile time but with commentOriginalSelector flag set to false. It also internally executes expandPartSelectors. While SSR hydration on the client this gets executed again but this time with commentOriginalSelectors set to true.

As far as i understood it (please correct my if i am wrong):

  • we should be able to drop expandPartSelectors if clause from runtime safely because its already done at compile time.
  • when scopeCss is executed while hydration only the comment part is altering the style everything else stays the same. => This implies extracting the commentOriginalSelectors part into its own function should be safely possible. Only this function should be executed on hydration to safe performance.
  • by executing the extracted componentOriginalSelectors function as well at compile time we can generate a source map for it (ignoring the actual output css). The source map should also be saved next to the component file.
  • while doing the hydration we can now attached the source map as reference based on shadowNeedsScopedCss

as alternative as this code is only executed if shadowNeedsScopedCss we could also demand that developer need a browser that supports scope native and therefor doesn't need to execute this logic

let me know if i can support you with something, ie the e2e test to verify everything maps back correctly.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: css source map support

2 participants