Files
next.js/test/e2e/app-dir/turbopack-postcss-multiple-configs/turbopack-postcss-multiple-configs.test.ts
74cdcde87e feat(turbopack): add LocalPathOrProjectPath PostCSS config resolution (#91338)
### What?

Adds a new `experimental.turbopackLocalPostcssConfig` option and
corresponding `PostCssConfigLocation::LocalPathOrProjectPath` variant so
Turbopack can resolve `postcss.config.js` starting from the CSS file's
directory first, falling back to the project root.

Also fixes an issue that surfaces when multiple per-directory
`postcss.config.js` files are used:

**Evaluate pool asset name collisions** — output file names for the
Node.js evaluate pool are now derived from an xxh3 hash of the full
module ident rather than the bare file name, so multiple
`postcss.config.js` files in different directories no longer collide.

### Why?

Currently Turbopack uses `ProjectPathOrLocalPath` which checks the
project root first, then falls back to the local directory. This means
per-directory `postcss.config.js` files cannot override the root config
— the root config always wins. For projects that need different PostCSS
transforms in different directories (e.g. a monorepo with multiple apps
or style directories), the more specific config should take precedence.

### How?

**Turbopack PostCSS resolution**
(`turbopack/crates/turbopack-node/src/transforms/postcss.rs`):
- Added `PostCssConfigLocation::LocalPathOrProjectPath` enum variant
with doc comments on all variants
- Refactored `find_config_in_location()` to express the search order as
a `Vec<FileSystemPath>` built from a single `match`, making each
variant's strategy immediately clear

**Evaluate pool asset file naming**
(`turbopack/crates/turbopack-node/src/evaluate.rs`):
- Replaced file-name-based output path derivation with a deterministic
xxh3 hash of the full module ident
- Adds `turbo-tasks-hash` dependency to `turbopack-node`
- Eliminates name collisions when multiple entries share the same file
name (e.g. per-directory `postcss.config.js` files)

**Next.js config flag** (`packages/next/src/server/config-shared.ts`,
`config-schema.ts`):
- Added `experimental.turbopackLocalPostcssConfig: boolean` option
(opt-in, default `false`)
- Wired through `crates/next-core/src/next_config.rs` → both client and
server contexts (`next_client/context.rs`, `next_server/context.rs`)
- When `true`, uses `LocalPathOrProjectPath`; when `false`/unset, uses
the existing `ProjectPathOrLocalPath` (no behavior change by default)

**E2E test** (`test/e2e/app-dir/turbopack-postcss-multiple-configs/`):
- Runs in both **Turbopack dev and production modes** — skips webpack
only (webpack does not support function-valued PostCSS plugins and the
`turbopackLocalPostcssConfig` flag is Turbopack-specific)
- 5 style directories, each with its own `postcss.config.js` passing a
**unique color option** (`blue`, `purple`, `orange`, `cyan`, `magenta`)
to a shared PostCSS plugin, proving that per-directory config resolution
produces distinct results rather than all sharing the same transform
- Root `postcss.config.js` is a no-op — if only the root config were
used, CSS would remain unmodified
- Test validates all 15 elements (5 dirs × 3 files) render with CSS
module classes
- Test collects all CSS (inline `<style>` tags for dev, linked `.css`
files for production) and asserts each directory's expected color is
present and `color: red` is absent

**Documentation**
(`docs/01-app/03-api-reference/05-config/01-next-config-js/turbopackLocalPostcssConfig.mdx`,
`docs/01-app/03-api-reference/08-turbopack.mdx`):
- New dedicated reference page for
`experimental.turbopackLocalPostcssConfig` covering usage, behavior
table, and a directory-tree example
- Added to the experimental options table in the Turbopack API reference

## Test plan

- [x] `cargo check -p turbo-tasks-backend` passes
- [x] `cargo check -p turbopack-node` and `cargo check -p next-core`
pass
- [x] E2E test `turbopack-postcss-multiple-configs` validates
per-directory PostCSS config resolution across 5 directories with
distinct per-directory colors (Turbopack dev and production modes)
- [x] Test correctly skips in webpack mode and deployment
- [x] Documentation page and experimental options table updated

<!-- NEXT_JS_LLM_PR -->

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Tobias Koppers <[email protected]>
2026-04-11 07:55:10 +02:00

98 lines
3.0 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
describe('turbopack-postcss-multiple-configs', () => {
const { next, isTurbopack, skipped } = nextTestSetup({
files: __dirname,
// Per-directory PostCSS config resolution is a Turbopack-only feature
// (turbopackLocalPostcssConfig). Webpack does not support this feature and
// does not accept function-valued PostCSS plugins, so skip non-Turbopack runs.
skipStart: true,
skipDeployment: true,
})
if (skipped) return
if (!isTurbopack) {
it('should only run with Turbopack', () => {})
return
}
beforeAll(async () => {
await next.start()
})
// Each directory's postcss.config.js passes a unique color option to the
// shared plugin, which replaces `color: red` with the given color.
// In production mode the CSS minifier may shorten named colors to hex
// (e.g. blue → #00f), so we match on patterns that cover both forms.
const DIR_COLORS: Record<number, string | RegExp> = {
1: /blue|#00f/,
2: /purple|#800080/,
3: /orange|#ffa500/,
4: /cyan|#0ff/,
5: /magenta|#f0f/,
}
const DIRS = 5
const FILES_PER_DIR = 3
it('should render all elements with CSS module classes applied', async () => {
const $ = await next.render$('/')
for (let dir = 1; dir <= DIRS; dir++) {
for (let file = 1; file <= FILES_PER_DIR; file++) {
const padded = String(file).padStart(2, '0')
const id = `dir${dir}-file${padded}`
const el = $(`#${id}`)
expect(el.length).toBe(1)
expect(el.text().trim()).toBe(`dir${dir} file${padded}`)
expect(el.attr('class')).toBeTruthy()
}
}
})
it('should apply per-directory PostCSS transforms with distinct colors', async () => {
const cssContent = await collectCss(next)
// Each directory's PostCSS config passes a unique color option.
// Verify every expected color appears in the output.
for (const [, pattern] of Object.entries(DIR_COLORS)) {
expect(cssContent).toMatch(pattern)
}
// No original `color: red` should remain — all were transformed.
expect(cssContent).not.toMatch(/color\s*:\s*red/)
// The old hardcoded green should NOT appear, proving options are used.
expect(cssContent).not.toMatch(/green|#0f0|#008000/)
})
})
/** Collect all CSS from the page: inline <style> tags + linked .css files. */
async function collectCss(next: any): Promise<string> {
const html = await next.render('/')
const parts: string[] = []
// Inline <style> content
const styleMatches = html.match(/<style[^>]*>([\s\S]*?)<\/style>/gi)
if (styleMatches) {
parts.push(...styleMatches)
}
// Linked CSS files
const hrefMatches = html.match(/href="([^"]*\.css[^"]*)"/gi)
if (hrefMatches) {
for (const match of hrefMatches) {
const href = match.match(/href="([^"]+)"/)?.[1]
if (href) {
const res = await next.fetch(href)
if (res.ok) {
parts.push(await res.text())
}
}
}
}
return parts.join('\n')
}