Add experimental.lightningCssFeatures config option (#90901)
### What?
Adds a new `experimental.lightningCssFeatures` config option that lets
users control which CSS features lightningcss should always transpile
(`include`) or never transpile (`exclude`), regardless of browserslist
targets.
```js
// next.config.js
module.exports = {
experimental: {
useLightningcss: true,
lightningCssFeatures: {
include: ['light-dark', 'oklab-colors'],
exclude: ['nesting'],
},
},
}
```
### Why?
Currently, lightningcss feature transpilation is determined solely by
browserslist targets. There's no way to force transpilation of a
specific feature (e.g., `light-dark()`) when targeting modern browsers
that already support it, or to skip transpilation of a feature that the
user wants to preserve as-is.
This is useful for:
- Forcing polyfill-style transpilation for features with incomplete
browser support
- Testing transpiled output for specific features
- Opting out of specific transforms that may interfere with CSS tooling
downstream
### How?
**TypeScript config & validation:**
- `config-shared.ts`: `LIGHTNINGCSS_FEATURE_NAMES` const array (single
source of truth) with `LightningCssFeature` type derived from it, and
`LightningCssFeatures` interface
- `config-schema.ts`: Zod validation using
`z.enum(LIGHTNINGCSS_FEATURE_NAMES)` for both include/exclude arrays
**Feature name → bitmask mapping (Rust, shared via NAPI):**
- `crates/next-core/src/next_config.rs`:
`lightningcss_feature_names_to_mask()` maps feature name strings to
`lightningcss::targets::Features` bitflag constants and returns a
`Result<u32>` bitmask. Unknown feature names produce an error via
`bail!`.
- Exposed to JS via NAPI as `lightningcssFeatureNamesToMaskNapi`,
callable through `bindings.css.lightning.featureNamesToMask(names)`
- Single source of truth for feature name resolution — both webpack and
Turbopack paths use this Rust function
**Webpack path:**
- `global.ts` / `modules.ts` pass config through to loader options
- `loader.ts` calls `featureNamesToMask()` via the native bindings to
compute include/exclude masks, then passes them to the SWC `transform()`
call
- The `lightningCssFeatures` without `useLightningcss` warning only
shows when using webpack (Turbopack always uses lightningcss)
**Turbopack path (Rust):**
- `next_config.rs`: `LightningCssFeatures` struct for deserialization,
accessor methods converting names → u32 bitmask via
`lightningcss_feature_names_to_mask()`
- Config flows through `CssOptionsContext` → `ModuleType::Css` →
`CssModuleAsset` (using `LightningCssFeatureFlags { include, exclude }`
struct) → `process.rs` where bitmasks are merged into
`lightningcss::targets::Targets { include, exclude }`
- Uses `u32` bitmask representation throughout to avoid adding
lightningcss dependency to non-CSS crates
**Defaults preserved:** `Nesting | MediaRangeSyntax` for Turbopack,
`Nesting` for Webpack. User `include` is OR-ed on top, user `exclude`
masks bits off.
**Feature names:** 21 individual features (bit 0–20) + 3 composite
groups (`selectors`, `media-queries`, `colors`), all in dash-case.
### Test
- **Include test**
(`test/e2e/app-dir/experimental-lightningcss-features/`) — targets
Chrome 123 (which natively supports `light-dark()`) with `include:
['light-dark']`, then asserts the CSS output contains lightningcss
transpilation markers (`--lightningcss-light`, `--lightningcss-dark`)
instead of raw `light-dark()`.
- **Exclude test**
(`test/e2e/app-dir/experimental-lightningcss-features-exclude/`) —
targets Chrome 100 (which does NOT support `light-dark()` natively) with
`exclude: ['light-dark']`, then asserts the CSS output preserves raw
`light-dark()` calls and does not contain transpilation markers.