Skip to content

Cascade-layer order is non-deterministic in dev (styles.configFile mode), letting the reset override component styles #381

Description

@timothycraig

vuetify-nuxt-module version

1.0.0-rc.1

Nuxt version

4.4.8

Vuetify version

4.1.2

Reproduction

see below

Steps to reproduce

Cascade-layer order is non-deterministic in dev (styles.configFile mode), letting the reset override component styles

Summary

In styles: { configFile } mode, the establishing @layer order declaration that
Vuetify ships in vuetify/lib/styles/generic/_layers.scss is not guaranteed to be
parsed before component styles
during nuxt dev. Because @vuetify/unplugin-styles
injects each component's CSS as a separate Vite style module at runtime, the cascade-layer
priority ends up decided by style-injection order, which is non-deterministic.

When vuetify-components happens to register its layer name before vuetify-core, the
reset rule button, input, … { font: inherit } (in vuetify-core.reset) outranks
component rules such as .v-btn--size-small { font-size: var(--v-btn-size) } (in
vuetify-components). The visible result: a <v-btn size="small"> (rendered as a real
<button>) intermittently renders at 16px (inherited body size) instead of 12px.

It is intermittent — different page loads of the same dev server, with no code change,
flip between 12px and 16px.

Environment

vuetify-nuxt-module 1.0.0-rc.1
vuetify 4.1.2
@vuetify/unplugin-styles 1.0.0-beta.11
Nuxt 4.4.8 (Nitro 2.13.4, Vite 7.3.6, Vue 3.5.39)
Node 24.18.0
OS / Browser macOS 14 (darwin 24.6.0) / Chrome (any layer-aware browser)

Relevant config (nuxt.config.ts):

export default defineNuxtConfig({
  modules: ['vuetify-nuxt-module'],
  vuetify: {
    moduleOptions: {
      styles: { configFile: './assets/css/settings.scss' },
    },
  },
})

assets/css/settings.scss:

@use 'vuetify/settings' with (
  $body-font-family: ('DM Sans', sans-serif),
);

Steps to reproduce

  1. Set up a Nuxt 4 app with vuetify-nuxt-module using styles.configFile (above).
  2. Put a real button (no to/href, so it renders as <button>, which the reset
    targets) somewhere visible:
    <v-btn size="small">Small</v-btn>
  3. npm run dev, open the page, inspect the button's computed font-size.
  4. Hard-refresh / open the page in several fresh tabs.

Expected: font-size: 12px every time (.v-btn--size-small--v-btn-size: .75rem).

Actual: font-size is intermittently 16px. DevTools shows the reset winning:

button, input, optgroup, select, textarea { font: inherit; }   @layer vuetify-core.reset   ← applied
.v-btn--size-small { font-size: var(--v-btn-size); }            @layer vuetify-components    ← struck through

Because the symptom depends on injection order, a manual repro is flaky. The script below
makes it deterministic and observable.

Deterministic reproduction script

Drives real Chrome against the running dev server (http://localhost:3000), injects a
<button class="v-btn v-btn--size-small">, and reports the computed font-size plus the
first-appearance index of each layer's block. Run several times — firstComp < firstCore
correlates with the 16px result.

// repro.mjs — node repro.mjs  (npm i puppeteer-core; uses system Chrome)
import puppeteer from 'puppeteer-core';

const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const BASE = 'http://localhost:3000';
const PAGES = ['/'];

const browser = await puppeteer.launch({ executablePath: CHROME, headless: 'shell' });
for (let i = 1; i <= 6; i++) {
  for (const path of PAGES) {
    const page = await browser.newPage();
    await page.setCacheEnabled(false);
    await page.goto(BASE + path, { waitUntil: 'domcontentloaded' });
    await new Promise(r => setTimeout(r, 1500));
    const r = await page.evaluate(() => {
      const b = document.createElement('button');
      b.className = 'v-btn v-btn--size-small';
      b.textContent = 'X';
      document.body.appendChild(b);
      let firstCore = Infinity, firstComp = Infinity, idx = 0;
      const walk = (rules) => { for (const rule of rules) { idx++;
        if (rule.constructor.name === 'CSSLayerBlockRule') {
          if (rule.name === 'vuetify-core' && firstCore === Infinity) firstCore = idx;
          if (rule.name === 'vuetify-components' && firstComp === Infinity) firstComp = idx;
        }
        if (rule.cssRules) walk(rule.cssRules);
      } };
      for (const s of document.styleSheets) { try { walk(s.cssRules); } catch {} }
      return { fontSize: getComputedStyle(b).fontSize, firstCore, firstComp };
    });
    console.log(`[${r.fontSize === '12px' ? 'ok ' : 'BUG'}] ${path}  font-size=${r.fontSize}  firstCore=${r.firstCore} firstComp=${r.firstComp}`);
    await page.close();
  }
}
await browser.close();

Observed output (same dev server, no code change)

[BUG] /  font-size=16px  firstCore=6678 firstComp=5984   ← components registered before core
[BUG] /  font-size=16px  firstCore=6644 firstComp=5984
[ok ] /  font-size=12px  firstCore=5992 firstComp=6153   ← core registered before components
...

The browser never sees an establishing @layer vuetify-core, vuetify-components, …;
statement in this mode — only block rules like @layer vuetify-components { … } — so the
order is whatever the injection sequence happens to be.

Root cause

  • In configFile mode the module relies on @vuetify/unplugin-styles to inject component
    CSS on demand. In dev these are individual Vite style modules added to <head> at
    runtime.
  • The establishing order block from _layers.scss
    (@layer vuetify-core { … } @layer vuetify-components; …) is effectively dropped /
    tree-shaken, so it does not pin the order ahead of component styles.
  • Per CSS Cascade 5, layer priority is set by the order layer names are first declared.
    With no establishing statement parsed first, first-declaration = first-injection, which
    Vite does not guarantee. When vuetify-components lands first it becomes the lower
    priority layer, and vuetify-core.reset (declared later) wins — so font: inherit
    overrides .v-btn--size-*.

Workaround (confirmed)

Emit the establishing layer order as an inline <style> in the SSR'd <head>, so it's
parsed during initial HTML parse, before any runtime-injected component style:

// nuxt.config.ts
app: {
  head: {
    style: [{
      innerHTML: '@layer vuetify-core,vuetify-components,vuetify-overrides,vuetify-utilities,vuetify-final;',
      tagPriority: -100,
    }],
  },
},

With this, the script reports 12px on 12/12 loads, even when firstComp < firstCore
(the inline statement has already locked the order before the blocks are seen).

Note: a css: ['~/layers.css'] file containing the same @layer statement does not
work in dev — it's just another runtime-injected style module and loses the same
injection-order race (~50% of loads still showed 16px). It has to be an inline head style.

Suggested fix

In configFile (and treeshaking) mode, have the module guarantee the establishing
@layer vuetify-core, vuetify-components, vuetify-overrides, vuetify-utilities, vuetify-final;
declaration is parsed before any component style — e.g. inject it as an inline head style
during SSR, or as the first entry of the styles graph in dev — so cascade-layer priority
doesn't depend on Vite's injection order.

Expected behavior

Buttons should always be the correct size

Actual behavior

Buttons are the incorrect size

System info

Additional context

No response

Validations

  • I have searched the existing issues and this is not a duplicate.
  • I have provided a minimal reproduction.
  • I have read the documentation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: stylesStyles, SCSS, FOUC, style configbugSomething isn't workingpriority: p2: highHigh: common bug, strong friction

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions