mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
### 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.
87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
|
|
|
/** Collect all CSS reachable from a page (inline <style> + linked .css). */
|
|
async function collectPageCss(
|
|
next: ReturnType<typeof nextTestSetup>['next'],
|
|
path: string
|
|
): Promise<string> {
|
|
const html = await (await next.fetch(path)).text()
|
|
let css = ''
|
|
// Inline <style> blocks
|
|
for (const m of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
|
|
css += m[1]
|
|
}
|
|
// External <link> stylesheets (href may contain query strings like ?dpl=...)
|
|
for (const m of html.matchAll(/<link[^>]*href="([^"]*\.css[^"]*?)"[^>]*>/g)) {
|
|
const res = await next.fetch(m[1])
|
|
if (res.ok) css += await res.text()
|
|
}
|
|
return css
|
|
}
|
|
|
|
describe('experimental-lightningcss-features', () => {
|
|
describe('include', () => {
|
|
const { next } = nextTestSetup({
|
|
files: __dirname,
|
|
dependencies: { lightningcss: '^1.23.0' },
|
|
// Chrome 123 supports light-dark() natively — using it here proves that
|
|
// the `include` flag forces transpilation regardless of browser support.
|
|
packageJson: {
|
|
browserslist: ['chrome 123'],
|
|
},
|
|
nextConfig: {
|
|
experimental: {
|
|
useLightningcss: true,
|
|
lightningCssFeatures: {
|
|
include: ['light-dark'],
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
it('should transpile light-dark() when included in lightningCssFeatures', async () => {
|
|
const html = await next.render('/')
|
|
expect(html).toContain('Hello')
|
|
|
|
// lightningcss transpiles light-dark(a, b) into fallback custom
|
|
// properties: var(--lightningcss-light, a) var(--lightningcss-dark, b).
|
|
const css = await collectPageCss(next, '/')
|
|
expect(css).not.toContain('light-dark(')
|
|
expect(css).toContain('--lightningcss-light')
|
|
expect(css).toContain('--lightningcss-dark')
|
|
})
|
|
})
|
|
|
|
describe('exclude', () => {
|
|
const { next } = nextTestSetup({
|
|
files: __dirname,
|
|
dependencies: { lightningcss: '^1.23.0' },
|
|
// Chrome 100 does NOT support light-dark() natively, so lightningcss would
|
|
// normally transpile it. Using `exclude: ['light-dark']` should prevent that.
|
|
packageJson: {
|
|
browserslist: ['chrome 100'],
|
|
},
|
|
nextConfig: {
|
|
experimental: {
|
|
useLightningcss: true,
|
|
lightningCssFeatures: {
|
|
exclude: ['light-dark'],
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
it('should preserve light-dark() when excluded from lightningCssFeatures', async () => {
|
|
const html = await next.render('/')
|
|
expect(html).toContain('Hello')
|
|
|
|
// With `exclude: ['light-dark']`, lightningcss should NOT transpile
|
|
// light-dark() — the raw function should remain in the output.
|
|
const css = await collectPageCss(next, '/')
|
|
expect(css).toContain('light-dark(')
|
|
expect(css).not.toContain('--lightningcss-light')
|
|
expect(css).not.toContain('--lightningcss-dark')
|
|
})
|
|
})
|
|
})
|