Skip to content

Commit

Permalink
feat!: if not processed, CSS Modules return a proxy, scope class name…
Browse files Browse the repository at this point in the history
…s by filename (vitest-dev#1803)
  • Loading branch information
sheremet-va authored and antfu committed Sep 4, 2022
1 parent f35f774 commit 21d4826
Show file tree
Hide file tree
Showing 19 changed files with 352 additions and 22 deletions.
22 changes: 18 additions & 4 deletions docs/config/index.md
Expand Up @@ -713,14 +713,12 @@ Show heap usage after each test. Useful for debugging memory leaks.

- **Type**: `boolean | { include?, exclude? }`

Configure if CSS should be processed. When excluded, CSS files will be replaced with empty strings to bypass the subsequent processing.

By default, processes only CSS Modules, because it affects runtime. JSDOM and Happy DOM don't fully support injecting CSS, so disabling this setting might help with performance.
Configure if CSS should be processed. When excluded, CSS files will be replaced with empty strings to bypass the subsequent processing. CSS Modules will return a proxy to not affect runtime.

#### css.include

- **Type**: `RegExp | RegExp[]`
- **Default**: `[/\.module\./]`
- **Default**: `[]`

RegExp pattern for files that should return actual CSS and will be processed by Vite pipeline.

Expand All @@ -731,6 +729,22 @@ RegExp pattern for files that should return actual CSS and will be processed by

RegExp pattern for files that will return an empty CSS file.

#### css.modules

- **Type**: `{ classNameStrategy? }`
- **Default**: `{}`

#### css.modules.classNameStrategy

- **Type**: `'stable' | 'scoped' | 'non-scoped'`
- **Default**: `'stable'`

If you decide to process CSS files, you can configure if class names inside CSS modules should be scoped. By default, Vitest exports a proxy, bypassing CSS Modules processing. You can choose one of the options:

- `stable`: class names will be generated as `_${name}_${hashedFilename}`, which means that generated class will stay the same, if CSS content is changed, but will change, if the name of the file is modified, or file is moved to another folder. This setting is useful, if you use snapshot feature.
- `scoped`: class names will be generated as usual, respecting `css.modules.generateScopeName` method, if you have one. By default, filename will be generated as `_${name}_${hash}`, where hash includes filename and content of the file.
- `non-scoped`: class names will stay as they are defined in CSS.

### maxConcurrency

- **Type**: `number`
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/defaults.ts
Expand Up @@ -83,7 +83,7 @@ const config = {
uiBase: '/__vitest__/',
open: true,
css: {
include: [/\.module\./],
include: [],
},
coverage: coverageConfigDefaults,
fakeTimers: fakeTimersDefaults,
Expand Down
20 changes: 20 additions & 0 deletions packages/vitest/src/integrations/css/css-modules.ts
@@ -0,0 +1,20 @@
import { createHash } from 'node:crypto'
import type { CSSModuleScopeStrategy } from '../../types'

export function generateCssFilenameHash(filepath: string) {
return createHash('md5').update(filepath).digest('hex').slice(0, 6)
}

export function generateScopedClassName(
strategy: CSSModuleScopeStrategy,
name: string,
filename: string,
) {
// should be configured by Vite defaults
if (strategy === 'scoped')
return null
if (strategy === 'non-scoped')
return name
const hash = generateCssFilenameHash(filename)
return `_${name}_${hash}`
}
6 changes: 4 additions & 2 deletions packages/vitest/src/node/config.ts
Expand Up @@ -197,8 +197,10 @@ export function resolveConfig(
resolved.passWithNoTests ??= true

resolved.css ??= {}
if (typeof resolved.css === 'object')
resolved.css.include ??= [/\.module\./]
if (typeof resolved.css === 'object') {
resolved.css.modules ??= {}
resolved.css.modules.classNameStrategy ??= 'stable'
}

resolved.cache ??= { dir: '' }
if (resolved.cache)
Expand Down
61 changes: 52 additions & 9 deletions packages/vitest/src/node/plugins/cssEnabler.ts
@@ -1,15 +1,30 @@
import { relative } from 'pathe'
import type { Plugin as VitePlugin } from 'vite'
import { generateCssFilenameHash } from '../../integrations/css/css-modules'
import type { CSSModuleScopeStrategy } from '../../types'
import { toArray } from '../../utils'
import type { Vitest } from '../core'

const cssLangs = '\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)'
const cssLangRE = new RegExp(cssLangs)
const cssModuleRE = new RegExp(`\\.module${cssLangs}`)

const isCSS = (id: string) => {
return cssLangRE.test(id)
}

export function CSSEnablerPlugin(ctx: Vitest): VitePlugin {
const isCSSModule = (id: string) => {
return cssModuleRE.test(id)
}

const getCSSModuleProxyReturn = (strategy: CSSModuleScopeStrategy, filename: string) => {
if (strategy === 'non-scoped')
return 'style'
const hash = generateCssFilenameHash(filename)
return `\`_\${style}_${hash}\``
}

export function CSSEnablerPlugin(ctx: Vitest): VitePlugin[] {
const shouldProcessCSS = (id: string) => {
const { css } = ctx.config
if (typeof css === 'boolean')
Expand All @@ -21,14 +36,42 @@ export function CSSEnablerPlugin(ctx: Vitest): VitePlugin {
return false
}

return {
name: 'vitest:css-enabler',
enforce: 'pre',
transform(code, id) {
if (!isCSS(id))
return
if (!shouldProcessCSS(id))
return [
{
name: 'vitest:css-disable',
enforce: 'pre',
transform(code, id) {
if (!isCSS(id))
return
// css plugin inside Vite won't do anything if the code is empty
// but it will put __vite__updateStyle anyway
if (!shouldProcessCSS(id))
return { code: '' }
},
},
{
name: 'vitest:css-empty-post',
enforce: 'post',
transform(_, id) {
if (!isCSS(id) || shouldProcessCSS(id))
return

if (isCSSModule(id)) {
// return proxy for css modules, so that imported module has names:
// styles.foo returns a "foo" instead of "undefined"
// we don't use code content to generate hash for "scoped", because it's empty
const scopeStrategy = (typeof ctx.config.css !== 'boolean' && ctx.config.css.modules?.classNameStrategy) || 'stable'
const proxyReturn = getCSSModuleProxyReturn(scopeStrategy, relative(ctx.config.root, id))
const code = `export default new Proxy(Object.create(null), {
get(_, style) {
return ${proxyReturn};
},
})`
return { code }
}

return { code: '' }
},
},
}
]
}
21 changes: 18 additions & 3 deletions packages/vitest/src/node/plugins/index.ts
@@ -1,23 +1,27 @@
import type { UserConfig as ViteConfig, Plugin as VitePlugin } from 'vite'
import { relative } from 'pathe'
import { configDefaults } from '../../defaults'
import type { ResolvedConfig, UserConfig } from '../../types'
import { deepMerge, ensurePackageInstalled, notNullish } from '../../utils'
import { resolveApiConfig } from '../config'
import { Vitest } from '../core'
import { generateScopedClassName } from '../../integrations/css/css-modules'
import { EnvReplacerPlugin } from './envReplacer'
import { GlobalSetupPlugin } from './globalSetup'
import { MocksPlugin } from './mock'
import { CSSEnablerPlugin } from './cssEnabler'
import { CoverageTransform } from './coverageTransform'

export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('test')): Promise<VitePlugin[]> {
const getRoot = () => ctx.config?.root || options.root || process.cwd()

async function UIPlugin() {
await ensurePackageInstalled('@vitest/ui', ctx.config?.root || options.root || process.cwd())
await ensurePackageInstalled('@vitest/ui', getRoot())
return (await import('@vitest/ui')).default(options.uiBase)
}

async function BrowserPlugin() {
await ensurePackageInstalled('@vitest/browser', ctx.config?.root || options.root || process.cwd())
await ensurePackageInstalled('@vitest/browser', getRoot())
return (await import('@vitest/browser')).default('/')
}

Expand Down Expand Up @@ -98,6 +102,17 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t
},
}

const classNameStrategy = preOptions.css && preOptions.css?.modules?.classNameStrategy

if (classNameStrategy !== 'scoped') {
config.css ??= {}
config.css.modules ??= {}
config.css.modules.generateScopedName = (name: string, filename: string) => {
const root = getRoot()
return generateScopedClassName(classNameStrategy, name, relative(root, filename))!
}
}

if (!options.browser) {
// disable deps optimization
Object.assign(config, {
Expand Down Expand Up @@ -164,7 +179,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t
...(options.browser
? await BrowserPlugin()
: []),
CSSEnablerPlugin(ctx),
...CSSEnablerPlugin(ctx),
CoverageTransform(ctx),
options.ui
? await UIPlugin()
Expand Down
6 changes: 5 additions & 1 deletion packages/vitest/src/types/config.ts
Expand Up @@ -13,6 +13,7 @@ import type { BenchmarkUserOptions } from './benchmark'
export type BuiltinEnvironment = 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime'
// Record is used, so user can get intellisense for builtin environments, but still allow custom environments
export type VitestEnvironment = BuiltinEnvironment | (string & Record<never, never>)
export type CSSModuleScopeStrategy = 'stable' | 'scoped' | 'non-scoped'

export type ApiConfig = Pick<CommonServerOptions, 'port' | 'strictPort' | 'host'>

Expand Down Expand Up @@ -385,11 +386,14 @@ export interface InlineConfig {
*
* When excluded, the CSS files will be replaced with empty strings to bypass the subsequent processing.
*
* @default { include: [/\.module\./] }
* @default { include: [], modules: { classNameStrategy: false } }
*/
css?: boolean | {
include?: RegExp | RegExp[]
exclude?: RegExp | RegExp[]
modules?: {
classNameStrategy?: CSSModuleScopeStrategy
}
}
/**
* A number of tests that are allowed to run at the same time marked with `test.concurrent`.
Expand Down
13 changes: 11 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions test/css/package.json
@@ -0,0 +1,12 @@
{
"name": "@vitest/test-css",
"private": true,
"scripts": {
"test": "node testing.mjs",
"coverage": "vitest run --coverage"
},
"devDependencies": {
"jsdom": "^20.0.0",
"vitest": "workspace:*"
}
}
4 changes: 4 additions & 0 deletions test/css/src/App.css
@@ -0,0 +1,4 @@
.main {
display: flex;
width: 100px;
}
7 changes: 7 additions & 0 deletions test/css/src/App.module.css
@@ -0,0 +1,7 @@
.main {
display: flex;
}

.module {
width: 100px;
}
38 changes: 38 additions & 0 deletions test/css/test/default-css.spec.ts
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'vitest'
import { useRemoveStyles } from './utils'

describe('don\'t process css by default', () => {
useRemoveStyles()

test('doesn\'t apply css', async () => {
await import('../src/App.css')

const element = document.createElement('div')
element.className = 'main'
const computed = window.getComputedStyle(element)
expect(computed.display).toBe('block')
expect(element).toMatchInlineSnapshot(`
<div
class="main"
/>
`)
})

test('module is not processed', async () => {
const { default: styles } = await import('../src/App.module.css')

// HASH is static, based on the filepath to root
expect(styles.module).toBe('_module_6dc87e')
expect(styles.someRandomValue).toBe('_someRandomValue_6dc87e')
const element = document.createElement('div')
element.className = '_module_6dc87e'
const computed = window.getComputedStyle(element)
expect(computed.display).toBe('block')
expect(computed.width).toBe('')
expect(element).toMatchInlineSnapshot(`
<div
class="_module_6dc87e"
/>
`)
})
})
18 changes: 18 additions & 0 deletions test/css/test/non-scope-module.spec.ts
@@ -0,0 +1,18 @@
import { expect, test } from 'vitest'

test('module is processed', async () => {
const { default: styles } = await import('../src/App.module.css')

expect(styles.module).toBe('module')
expect(styles.someRandomValue).toBeUndefined()
const element = document.createElement('div')
element.className = `${styles.main} ${styles.module}`
const computed = window.getComputedStyle(element)
expect(computed.display).toBe('flex')
expect(computed.width).toBe('100px')
expect(element).toMatchInlineSnapshot(`
<div
class="main module"
/>
`)
})

0 comments on commit 21d4826

Please sign in to comment.