nextjs - 💡(How to fix) Fix Turbopack + Lightning CSS: `lightningCssFeatures.include: ['custom-media-queries']` does not expand `@custom-media` — `ParserFlags::CUSTOM_MEDIA` is never set [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
vercel/next.js#93165Fetched 2026-04-24 05:50:40
View on GitHub
Comments
1
Participants
2
Timeline
7
Reactions
0
Author
Timeline (top)
labeled ×3closed ×1commented ×1issue_type_added ×1

Root Cause

Lightning CSS needs two flags to transpile @custom-media:

  1. ParserFlags::CUSTOM_MEDIA on the parser — otherwise @custom-media rules are parsed as UnknownAtRule and preserved verbatim.
  2. Features::CustomMediaQueries in Targets.include on the transformer — so the rules get downleveled.

Turbopack sets only #2. In turbopack/crates/turbopack-css/src/process.rs, process_content builds ParserOptions like this:

let config = ParserOptions {
    css_modules: match ty { /* … */ },
    filename: filename.to_string(),
    error_recovery: true,
    ..Default::default()   // flags: ParserFlags::empty()  ← the bug
};

get_lightningcss_browser_targets in the same file correctly ORs Features::CustomMediaQueries into Targets.include, but that only controls downleveling; it never turns on the parser flag. grep across turbopack/crates/turbopack-css/ for ParserFlags, CUSTOM_MEDIA, Drafts, custom_media returns zero matches.

Fix Action

Fix / Workaround

The lightningCssFeatures option was added in #90901 (released in 16.2.0). The PR routes feature-name bitflags into Targets { include, exclude } but does not touch ParserOptions.flags. The e2e fixtures under test/e2e/app-dir/experimental-lightningcss-features*/ cover only light-dark and nesting; there is no test for custom-media-queries.

Code Example

import type { NextConfig } from 'next';

    const nextConfig: NextConfig = {
      experimental: {
        useLightningcss: true,
        lightningCssFeatures: {
          include: ['custom-media-queries'],
        },
      },
    };

    export default nextConfig;

---

@custom-media --md (min-width: 62em);

    .root {
      display: flex;
      align-items: center;
      min-height: 100vh;

      @media (--md) {
        display: none;
      }
    }

---

import styles from './page.module.css';
    export default function Home() {
      return <div className={styles.root}>Resize me</div>;
    }

---

@custom-media --md (min-width: 62em);
.page-module__…__root{display:flex;align-items:center;min-height:100vh}
@media (--md){.page-module__…__root{display:none}}

---

.page-module__…__root{display:flex;align-items:center;min-height:100vh}
@media (width >= 62em){.page-module__…__root{display:none}}

---

let config = ParserOptions {
    css_modules: match ty { /* … */ },
    filename: filename.to_string(),
    error_recovery: true,
    ..Default::default()   // flags: ParserFlags::empty()  ← the bug
};

---

const { lightningcssFeatureNamesToMaskNapi, lightningCssTransform } =
  require('@next/swc-linux-x64-gnu/next-swc.linux-x64-gnu.node');

const input = `
@custom-media --md (min-width: 62em);
.root { display: flex; @media (--md) { display: none; } }
`;
const mask = lightningcssFeatureNamesToMaskNapi(['custom-media-queries']); // 256

// What Next.js does today:
lightningCssTransform({
  filename: 'test.css', code: Buffer.from(input),
  include: (1 | mask) >>> 0, exclude: 0,
}).code.toString();
// @custom-media --md (min-width: 62em);
// .root { display: flex; }
// @media (--md) { .root { display: none; } }   ← broken

// With drafts / ParserFlags::CUSTOM_MEDIA enabled:
lightningCssTransform({
  filename: 'test.css', code: Buffer.from(input),
  include: (1 | mask) >>> 0, exclude: 0,
  drafts: { customMedia: true },
}).code.toString();
// .root { display: flex; }
// @media (width >= 62em) { .root { display: none; } }   ← expected

---

use lightningcss::stylesheet::ParserFlags;
use lightningcss::targets::Features;

let mut parser_flags = ParserFlags::empty();
if feature_flags.include & Features::CustomMediaQueries.bits() != 0 {
    parser_flags |= ParserFlags::CUSTOM_MEDIA;
}

let config = ParserOptions {
    css_modules: /* … */,
    filename: filename.to_string(),
    error_recovery: true,
    flags: parser_flags,
    ..Default::default()
};

---

Operating System:
  Platform: linux
  Arch: x64
Binaries:
  Node: 24.8.0
  npm: 11.6.0
Relevant Packages:
  next: 16.2.4 (also reproduced on 16.3.0-canary.2)
  eslint-config-next: N/A
  react: 19.2.4
  react-dom: 19.2.4
  typescript: 5.9.3
Next.js Config:
  experimental.useLightningcss: true
  experimental.lightningCssFeatures.include: ['custom-media-queries']
RAW_BUFFERClick to expand / collapse

Link to the code that reproduces this issue

https://github.com/

To Reproduce

  1. next.config.ts:

    import type { NextConfig } from 'next';
    
    const nextConfig: NextConfig = {
      experimental: {
        useLightningcss: true,
        lightningCssFeatures: {
          include: ['custom-media-queries'],
        },
      },
    };
    
    export default nextConfig;
  2. app/page.module.css:

    @custom-media --md (min-width: 62em);
    
    .root {
      display: flex;
      align-items: center;
      min-height: 100vh;
    
      @media (--md) {
        display: none;
      }
    }
  3. app/page.tsx:

    import styles from './page.module.css';
    export default function Home() {
      return <div className={styles.root}>Resize me</div>;
    }
  4. Run next build (Turbopack is the default) and inspect the emitted CSS in .next/static/chunks/*.css.

Current vs. Expected behavior

Current (reproduced on 16.2.4 stable and 16.3.0-canary.2):

The emitted CSS preserves @custom-media declarations and leaves @media (--md) references unresolved. No browser natively understands these, so the media query never matches:

@custom-media --md (min-width: 62em);
.page-module__…__root{display:flex;align-items:center;min-height:100vh}
@media (--md){.page-module__…__root{display:none}}

Expected@custom-media declarations are stripped and @media (--md) is replaced with the resolved condition:

.page-module__…__root{display:flex;align-items:center;min-height:100vh}
@media (width >= 62em){.page-module__…__root{display:none}}

This matches the docs for lightningCssFeatures, which list custom-media-queries as a supported individual feature (mapped to @custom-media rules).

Root cause

Lightning CSS needs two flags to transpile @custom-media:

  1. ParserFlags::CUSTOM_MEDIA on the parser — otherwise @custom-media rules are parsed as UnknownAtRule and preserved verbatim.
  2. Features::CustomMediaQueries in Targets.include on the transformer — so the rules get downleveled.

Turbopack sets only #2. In turbopack/crates/turbopack-css/src/process.rs, process_content builds ParserOptions like this:

let config = ParserOptions {
    css_modules: match ty { /* … */ },
    filename: filename.to_string(),
    error_recovery: true,
    ..Default::default()   // flags: ParserFlags::empty()  ← the bug
};

get_lightningcss_browser_targets in the same file correctly ORs Features::CustomMediaQueries into Targets.include, but that only controls downleveling; it never turns on the parser flag. grep across turbopack/crates/turbopack-css/ for ParserFlags, CUSTOM_MEDIA, Drafts, custom_media returns zero matches.

Direct verification via the bundled N-API binding

Calling the same Lightning CSS transform Next.js ships — first without, then with drafts.customMedia:

const { lightningcssFeatureNamesToMaskNapi, lightningCssTransform } =
  require('@next/swc-linux-x64-gnu/next-swc.linux-x64-gnu.node');

const input = `
@custom-media --md (min-width: 62em);
.root { display: flex; @media (--md) { display: none; } }
`;
const mask = lightningcssFeatureNamesToMaskNapi(['custom-media-queries']); // 256

// What Next.js does today:
lightningCssTransform({
  filename: 'test.css', code: Buffer.from(input),
  include: (1 | mask) >>> 0, exclude: 0,
}).code.toString();
// @custom-media --md (min-width: 62em);
// .root { display: flex; }
// @media (--md) { .root { display: none; } }   ← broken

// With drafts / ParserFlags::CUSTOM_MEDIA enabled:
lightningCssTransform({
  filename: 'test.css', code: Buffer.from(input),
  include: (1 | mask) >>> 0, exclude: 0,
  drafts: { customMedia: true },
}).code.toString();
// .root { display: flex; }
// @media (width >= 62em) { .root { display: none; } }   ← expected

Related PR and missing test

The lightningCssFeatures option was added in #90901 (released in 16.2.0). The PR routes feature-name bitflags into Targets { include, exclude } but does not touch ParserOptions.flags. The e2e fixtures under test/e2e/app-dir/experimental-lightningcss-features*/ cover only light-dark and nesting; there is no test for custom-media-queries.

Suggested fix

In process_content, derive ParserFlags from feature_flags.include:

use lightningcss::stylesheet::ParserFlags;
use lightningcss::targets::Features;

let mut parser_flags = ParserFlags::empty();
if feature_flags.include & Features::CustomMediaQueries.bits() != 0 {
    parser_flags |= ParserFlags::CUSTOM_MEDIA;
}

let config = ParserOptions {
    css_modules: /* … */,
    filename: filename.to_string(),
    error_recovery: true,
    flags: parser_flags,
    ..Default::default()
};

Provide environment information

Operating System:
  Platform: linux
  Arch: x64
Binaries:
  Node: 24.8.0
  npm: 11.6.0
Relevant Packages:
  next: 16.2.4 (also reproduced on 16.3.0-canary.2)
  eslint-config-next: N/A
  react: 19.2.4
  react-dom: 19.2.4
  typescript: 5.9.3
Next.js Config:
  experimental.useLightningcss: true
  experimental.lightningCssFeatures.include: ['custom-media-queries']

Which area(s) are affected? (Select all that apply)

CSS, Turbopack

Which stage(s) are affected? (Select all that apply)

next dev (local), next build (local), next start (local)

Additional context

Verified on both 16.2.4 (stable) and 16.3.0-canary.2. The feature was introduced in 16.2.0 via #90901, and the missing ParserFlags::CUSTOM_MEDIA has been there since. Direct invocation of the bundled native binding (shown above) confirms the underlying Lightning CSS transform works correctly once drafts.customMedia: true / ParserFlags::CUSTOM_MEDIA is set — the issue is purely in how Turbopack wires up ParserOptions.

extent analysis

TL;DR

The most likely fix is to update the ParserOptions in Turbopack to include ParserFlags::CUSTOM_MEDIA when custom-media-queries is enabled.

Guidance

  1. Verify the issue: Confirm that the problem is specific to the custom-media-queries feature in Lightning CSS and that it's not working as expected in the current version of Next.js.
  2. Update Turbopack: Modify the process_content function in Turbopack to derive ParserFlags from feature_flags.include and set ParserFlags::CUSTOM_MEDIA when custom-media-queries is enabled.
  3. Test the fix: Use the direct verification method via the bundled N-API binding to test the updated ParserOptions and ensure that @custom-media declarations are correctly transpiled.
  4. Submit a PR: Create a pull request to update the Turbopack code and include a test for the custom-media-queries feature to prevent similar issues in the future.

Example

The suggested fix in Rust:

let mut parser_flags = ParserFlags::empty();
if feature_flags.include & Features::CustomMediaQueries.bits()!= 0 {
    parser_flags |= ParserFlags::CUSTOM_MEDIA;
}

let config = ParserOptions {
    css_modules: /* … */,
    filename: filename.to_string(),
    error_recovery: true,
    flags: parser_flags,
   ..Default::default()
};

Notes

This fix assumes that the issue is specific to the custom-media-queries feature in Lightning CSS and that updating the ParserOptions in Turbopack will resolve the problem. Further testing and verification may be necessary to ensure that the fix works as expected.

Recommendation

Apply the suggested fix to update the ParserOptions in Turbopack, as it directly addresses the root cause of the issue and provides a clear solution

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING