Skip to content

CSS Modules: @extend (postcss-extend-rule) silently dropped — CSS-Modules plugin runs before user PostCSS plugins #2992

Description

@james-elicx

Problem

@extend from postcss-extend-rule is silently discarded in CSS Modules. Every declaration the rule should have inherited is missing — in dev and in the production build alike — with no warning or error.

/* styles.module.css */
.shared { position: absolute; visibility: hidden; }
.wrap   { @extend .shared; border: 1px solid red; }

Expected: .wrap inherits position: absolute; visibility: hidden.

Actual output:

._shared_1u9uh_1{visibility:hidden;position:absolute}
._wrap_1u9uh_2{border:1px solid red}

The at-rule is gone and nothing was copied.

Cause

Vite's compileCSS puts the CSS-Modules plugin at the front of the PostCSS chain:

if (isModule) postcssPlugins.unshift((await importPostcssModules()).default({ ... }))

So class names are already hashed to ._shared_1u9uh_1 by the time the project's postcss-extend-rule runs. @extend .shared matches no rule, and the plugin drops it silently.

Running the same PostCSS config standalone over the same file resolves the extend correctly, so the configuration is fine — only the ordering inside Vite's pipeline is wrong.

Reproduction (no vinext involved)

postcss.config.mjs   ->  export default { plugins: { 'postcss-extend-rule': {} } }
src/styles.module.css -> the two rules above
src/main.js          ->  import s from "./styles.module.css"
vite build

The emitted CSS shows _wrap_ without the extended declarations. This reproduces on plain Vite with no vinext in the project, so the underlying issue is Vite's plugin ordering — filing here because vinext already resolves and injects the project's PostCSS config, and is where a Next-compatible pipeline would be expected to handle it (webpack/css-loader runs the user's PostCSS before CSS-Modules scoping, so the same stylesheet works under next build).

Impact

Any CSS module using @extend loses those declarations. In one Pages Router app this silently affected 46 @extend uses across 11 module files, producing components with missing positioning — dropdown panels rendering in flow instead of as absolutely-positioned overlays, etc. Because nothing warns, it presents as "the CSS is just wrong" with no obvious cause.

Workaround

Expanding @extend before Vite's pipeline sees the file, via an enforce: "pre" transform, fixes it — the extend resolves against the original selectors and Vite then hashes the already-expanded rules:

const expandCssModuleExtends = () => {
  let processor;
  return {
    name: "expand-css-module-extends",
    enforce: "pre" as const,
    async transform(code: string, id: string) {
      const file = id.split("?")[0];
      if (!file.endsWith(".module.css") || !code.includes("@extend")) return null;
      if (!processor) {
        const postcss = (await import("postcss")).default;
        const extendRule = (await import("postcss-extend-rule")).default;
        const presetEnv = (await import("postcss-preset-env")).default;
        // `@extend` must see flat selectors: postcss-extend-rule skips nested
        // rules whose selector it cannot resolve, so flatten nesting first.
        processor = postcss([
          presetEnv({ stage: false, features: { "nesting-rules": true } }),
          extendRule(),
        ]);
      }
      const result = await processor.process(code, { from: file });
      return { code: result.css, map: null };
    },
  };
};

Note the nesting step — without it only the @extends at one nesting depth are expanded, which looks like a partial fix.

Environment

  • vinext 1.0.0-beta.6
  • Vite 8.1.0
  • postcss-extend-rule, postcss-preset-env
  • Reproduced in dev and in the production build, and on plain Vite without vinext

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions