Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/guide/styling/common-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,77 @@ export default defineNuxtConfig({
```

When using `configFile`, you can also enable [Experimental Caching](/guide/styling/caching) to improve build performance.

### Cascade Layers

Vuetify 4 organizes its styles with [CSS cascade layers](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer). Because component styles are injected on demand, their relative order — and therefore layer priority — would otherwise depend on injection order, which is non-deterministic in dev and chunk-dependent in production. This can let Vuetify's reset outrank component rules (for example a `<v-btn size="small">` rendering at the wrong font size).

To make it deterministic, the module inlines the establishing layer order into the SSR'd `<head>` before any component style is parsed. This happens automatically on **Vuetify 4** and needs no configuration.

The default order is:

```css
@layer vuetify-core, vuetify-components, vuetify-overrides, vuetify-utilities, vuetify-final;
```

::: info
This applies to **Vuetify 4** only and is skipped when `styles` is `'none'`. Vuetify 3's layers are opt-in, use different names, and live under a single top-level `vuetify` layer.
:::

#### Custom order

A flat `@layer` statement freezes the listed layers contiguously, so a layer you declare later can only be appended after them. If you need your own layer to sit **between** Vuetify's layers, provide the full order via `cascadeLayers`. Known Vuetify layer names are offered as autocomplete suggestions, and any custom name is allowed.

```ts
export default defineNuxtConfig({
modules: ['vuetify-nuxt-module'],
vuetify: {
moduleOptions: {
cascadeLayers: [
'vuetify-core',
'vuetify-components',
'my-overrides', // beats components, loses to vuetify-overrides
'vuetify-overrides',
'vuetify-utilities',
'vuetify-final'
]
}
}
})
```

Your list should include Vuetify's layers, otherwise the ordering guarantee is lost for any omitted layer.

#### Opt out

Set `cascadeLayers` to `false` to inject nothing and manage the cascade-layer order yourself.

```ts
export default defineNuxtConfig({
modules: ['vuetify-nuxt-module'],
vuetify: {
moduleOptions: {
cascadeLayers: false
}
}
})
```

#### Migrating from a manual workaround

Earlier versions had no fix for the layer-order race, so a common workaround was to declare the layer order yourself — typically an inline head style:

```ts
// no longer needed
app: {
head: {
style: [{
innerHTML: '@layer vuetify-core,vuetify-components,vuetify-overrides,vuetify-utilities,vuetify-final;',
tagPriority: -100
}]
}
}
```

- **If your workaround used the default order** (as above), you can simply **remove it** — the module now injects the same statement. Leaving it in place is harmless (re-declaring the same order is a no-op), just redundant.
- **If your workaround declared a custom order** to slot your own layer between Vuetify's — especially via a `css: ['~/layers.css']` file or a `@layer` line in your `configFile` SCSS — move that order to [`cascadeLayers`](#custom-order). Otherwise the module's default statement, parsed first, establishes Vuetify's layers contiguously and your custom layer can only end up after them. Alternatively, set `cascadeLayers: false` to keep your own workaround authoritative.
41 changes: 41 additions & 0 deletions packages/vuetify-nuxt-module/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ export type LabComponentName = keyof typeof import('vuetify/labs/components')
export type LabComponents = boolean | LabComponentName | LabComponentName[]
export type VuetifyLocale = keyof typeof import('vuetify/locale')

/**
* A CSS cascade-layer name for `cascadeLayers`. Vuetify 4's built-in layer
* names are suggested for autocomplete, while any custom layer name is allowed.
*/
export type VuetifyCascadeLayer
= | 'vuetify-core'
| 'vuetify-components'
| 'vuetify-overrides'
| 'vuetify-utilities'
| 'vuetify-final'
// `string & {}` keeps the literal suggestions above for autocomplete while
// still allowing arbitrary custom layer names.
| (string & {})

export interface VOptions extends Partial<Omit<VuetifyOptions, | 'ssr' | 'aliases' | 'components' | 'directives' | 'locale' | 'date' | 'icons'>> {
/**
* Configure the SSR options.
Expand Down Expand Up @@ -289,6 +303,33 @@ export interface MOptions {
*/
utilities?: boolean
}
/**
* Establishing CSS cascade-layer order, inlined into the SSR'd `<head>`.
*
* In treeshaking modes Vuetify's per-component styles are injected on demand,
* each carrying its own `@layer vuetify-components { … }` block. Their order
* is otherwise decided by injection sequence (non-deterministic in dev, chunk
* order in prod), which can let `vuetify-core.reset` outrank component rules
* (e.g. `<v-btn size="small">` renders at the wrong font-size). Declaring the
* layer order once, before any component style is parsed, makes it
* deterministic. See https://github.com/vuetifyjs/nuxt-module/issues/381.
*
* - **omit** (default) — inject Vuetify's order:
* `vuetify-core, vuetify-components, vuetify-overrides, vuetify-utilities, vuetify-final`.
* - **`string[]`** — inject your own order, e.g. to slot a custom layer
* between Vuetify's (a flat `@layer` statement freezes the named layers
* contiguously, so a later-declared new layer can only append). Your list
* should include Vuetify's layers, or the race it fixes can reappear for
* any omitted layer.
* - **`false`** — inject nothing; manage the cascade-layer order yourself.
*
* Vuetify 4 only — ignored on Vuetify 3 (layers are opt-in there, use
* different names, and live under a single top-level `vuetify` layer) and
* when `styles` is `'none'`.
*
* @since v1.0.0
*/
cascadeLayers?: VuetifyCascadeLayer[] | false
/**
* Disable the modern SASS compiler and API.
*
Expand Down
8 changes: 7 additions & 1 deletion packages/vuetify-nuxt-module/src/utils/configure-nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { VuetifyNuxtContext } from './config'
import { addImports, addPlugin, addTemplate, extendWebpackConfig, isNuxtMajorVersion, resolvePath } from '@nuxt/kit'
import { RESOLVED_VIRTUAL_MODULES } from '../vite/constants'
import { toKebabCase } from './index'
import { resolveVuetifyConfigFile } from './styles'
import { applyCascadeLayersHeadStyle, resolveVuetifyConfigFile } from './styles'
import { addVuetifyNuxtPlugins } from './vuetify-nuxt-plugins'

export function getTemplate (source: string, settings: string | null): string {
Expand Down Expand Up @@ -55,6 +55,12 @@ export async function configureNuxt (
}
}

// Inline the establishing cascade-layer order into the SSR'd <head> so layer
// priority is parsed before any runtime-injected component <style>. Otherwise
// injection order decides priority and `vuetify-core.reset` can outrank
// component rules (#381). Vuetify 4 only; opt out / customise via cascadeLayers.
applyCascadeLayersHeadStyle(nuxt, styles, ctx.moduleOptions.cascadeLayers, ctx.vuetifyGte('4.0.0'))

// transpile always vuetify and runtime folder
nuxt.options.build.transpile.push(configKey, runtimeDir)
if (ctx.enableRules) {
Expand Down
63 changes: 63 additions & 0 deletions packages/vuetify-nuxt-module/src/utils/styles.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,70 @@
import type { Nuxt } from '@nuxt/schema'
import type { MOptions } from '../types'
import { existsSync } from 'node:fs'
import { isAbsolute, resolve } from 'pathe'

/**
* Vuetify 4 ships the establishing cascade-layer order in
* `vuetify/lib/styles/generic/_layers.scss`, but in treeshaking modes the
* per-component styles are injected on demand by `@vuetify/unplugin-styles` —
* each carrying its own `@layer vuetify-components { … }` block. Their DOM
* order is decided by injection sequence (non-deterministic in dev, chunk
* order in prod), so `vuetify-components` can be declared before `vuetify-core`,
* making `vuetify-core.reset` (`button { font: inherit }`) outrank
* `.v-btn--size-*`. See #381.
*
* Flattened to its top-level order, which is all that decides the reported
* race (core vs components).
*/
const VUETIFY4_CASCADE_LAYERS = '@layer vuetify-core,vuetify-components,vuetify-overrides,vuetify-utilities,vuetify-final;'

/**
* Establishing layer-order `<style>` to inline into the SSR'd `<head>` so the
* cascade-layer priority is parsed before any runtime-injected component style.
*
* Vuetify 4 only: v3's layers are opt-in (since 3.8), use different names, and
* live under a single top-level `vuetify` layer (no top-level race to fix).
* Skipped for `styles: 'none'`/`false`, where the consumer owns the cascade.
*
* `cascadeLayers` lets a consumer redefine the order — e.g. to slot a custom
* layer between Vuetify's — since a flat establishing statement freezes the
* named layers contiguously (later-declared new layers can only append). Pass
* `false` to opt out and manage the order yourself; omit it for Vuetify's
* default order.
*/
export function resolveCascadeLayersHeadStyle (
styles: MOptions['styles'],
cascadeLayers: MOptions['cascadeLayers'],
isVuetify4: boolean,
): { innerHTML: string, tagPriority: number } | undefined {
if (!isVuetify4 || styles === 'none' || (styles as unknown) === false || cascadeLayers === false) {
return undefined
}
const innerHTML = Array.isArray(cascadeLayers)
? (cascadeLayers.length > 0 ? `@layer ${cascadeLayers.join(',')};` : undefined)
: VUETIFY4_CASCADE_LAYERS
return innerHTML === undefined ? undefined : { innerHTML, tagPriority: -100 }
}

/**
* Resolve and append the establishing cascade-layer `<style>` to the SSR'd
* `<head>`, if applicable. Thin wrapper over {@link resolveCascadeLayersHeadStyle}
* so the decision stays pure and testable while keeping the caller branch-free.
*/
export function applyCascadeLayersHeadStyle (
nuxt: Nuxt,
styles: MOptions['styles'],
cascadeLayers: MOptions['cascadeLayers'],
isVuetify4: boolean,
): void {
const style = resolveCascadeLayersHeadStyle(styles, cascadeLayers, isVuetify4)
if (!style) {
return
}
nuxt.options.app.head.style ??= []
nuxt.options.app.head.style.push(style)
}

export function resolveVuetifyConfigFile (configFile: string, nuxt: Nuxt) {
if (typeof configFile === 'string' && !isAbsolute(configFile)) {
for (const layer of nuxt.options._layers) {
Expand Down
5 changes: 5 additions & 0 deletions packages/vuetify-nuxt-module/test/basic-vuetify3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ describe('ssr with vuetify 3', async () => {
const html = await $fetch('/')
expect(html).contain('v-application')
})

it('does not inline the Vuetify 4 cascade-layer order (#381 — v3 layers differ and are opt-in)', async () => {
const html = await $fetch('/')
expect(html).not.toContain('@layer vuetify-core')
})
})
79 changes: 79 additions & 0 deletions packages/vuetify-nuxt-module/test/cascade-layers-head.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { resolveCascadeLayersHeadStyle } from '../src/utils/styles'

// The establishing statement Vuetify 4 ships in `generic/_layers.scss`, flattened
// to its top-level order. Pinning this before any runtime-injected component
// <style> guarantees `vuetify-components` outranks `vuetify-core.reset`.
const V4_STATEMENT = '@layer vuetify-core,vuetify-components,vuetify-overrides,vuetify-utilities,vuetify-final;'

describe('resolveCascadeLayersHeadStyle', () => {
describe('Vuetify 4 — default order (cascadeLayers undefined)', () => {
it('emits the establishing layer order for styles: { configFile }', () => {
const style = resolveCascadeLayersHeadStyle({ configFile: 'settings.scss' }, undefined, true)
expect(style).toEqual({ innerHTML: V4_STATEMENT, tagPriority: -100 })
})

it('emits it for styles: true', () => {
expect(resolveCascadeLayersHeadStyle(true, undefined, true)).toEqual({ innerHTML: V4_STATEMENT, tagPriority: -100 })
})

it('emits it for the default (styles undefined)', () => {
expect(resolveCascadeLayersHeadStyle(undefined, undefined, true)).toEqual({ innerHTML: V4_STATEMENT, tagPriority: -100 })
})

it('emits it for object styles without configFile', () => {
expect(resolveCascadeLayersHeadStyle({ colors: false, utilities: false }, undefined, true)).toEqual({ innerHTML: V4_STATEMENT, tagPriority: -100 })
})

it('keeps vuetify-core before vuetify-components so the reset cannot win', () => {
const style = resolveCascadeLayersHeadStyle(true, undefined, true)!
const core = style.innerHTML.indexOf('vuetify-core')
const components = style.innerHTML.indexOf('vuetify-components')
expect(core).toBeGreaterThanOrEqual(0)
expect(core).toBeLessThan(components)
})
})

describe('Vuetify 4 — custom order (cascadeLayers array)', () => {
it('injects the user-defined order so a layer can sit between Vuetify layers', () => {
const order = ['vuetify-core', 'vuetify-components', 'my-overrides', 'vuetify-overrides', 'vuetify-utilities', 'vuetify-final']
const style = resolveCascadeLayersHeadStyle(true, order, true)
expect(style).toEqual({
innerHTML: '@layer vuetify-core,vuetify-components,my-overrides,vuetify-overrides,vuetify-utilities,vuetify-final;',
tagPriority: -100,
})
})

it('treats an empty array as nothing to establish', () => {
expect(resolveCascadeLayersHeadStyle(true, [], true)).toBeUndefined()
})
})

describe('opt-out', () => {
it('does not emit when cascadeLayers is false', () => {
expect(resolveCascadeLayersHeadStyle(true, false, true)).toBeUndefined()
})

it('does not emit when styles is "none" (user owns the cascade)', () => {
expect(resolveCascadeLayersHeadStyle('none', undefined, true)).toBeUndefined()
})

it('does not emit when styles is false', () => {
expect(resolveCascadeLayersHeadStyle(false as never, undefined, true)).toBeUndefined()
})

it('cascadeLayers: false wins even with a custom-looking styles object', () => {
expect(resolveCascadeLayersHeadStyle({ configFile: 'settings.scss' }, false, true)).toBeUndefined()
})
})

describe('Vuetify 3 (different, opt-in layers)', () => {
it('never emits the v4 statement, even with configFile', () => {
expect(resolveCascadeLayersHeadStyle({ configFile: 'settings.scss' }, undefined, false)).toBeUndefined()
})

it('never emits even when a custom order is given', () => {
expect(resolveCascadeLayersHeadStyle(true, ['a', 'b'], false)).toBeUndefined()
})
})
})
7 changes: 7 additions & 0 deletions packages/vuetify-nuxt-module/test/sass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,11 @@ describe('sass', async () => {
const html = await $fetch('/')
expect(html).toContain('Hi')
})

it('inlines the Vuetify 4 establishing cascade-layer order in the SSR head (#381)', async () => {
// Pins layer priority before any runtime-injected component <style>, so the
// `vuetify-core.reset` cannot outrank `.v-btn--size-*`.
const html = await $fetch('/')
expect(html).toContain('@layer vuetify-core,vuetify-components,vuetify-overrides,vuetify-utilities,vuetify-final;')
})
})