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
159 changes: 143 additions & 16 deletions packages/vite/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,14 +514,64 @@ if ('document' in globalThis) {
})
}

// all css imports should be inserted at the same position
// because after build it will be a single css file
let lastInsertedStyle: HTMLStyleElement | undefined
// Track which CSS files have been successfully inserted
const insertedCSS = new Set<string>()
// Queue for CSS files waiting for their dependencies
const pendingCSS = new Map<string, { css: string; deps: string[] }>()

export function updateStyle(id: string, content: string): void {
/**
* Track the last inserted Vite CSS for maintaining arrival order.
* Reset via setTimeout to separate chunks loaded at different times.
*/
let lastInsertedViteCSS: HTMLStyleElement | null = null

/**
* Update or insert a CSS style element with dependency-aware ordering.
*
* In dev mode, CSS files may load in arbitrary order due to parallel dynamic imports.
* This function ensures CSS is inserted in dependency order (dependencies before dependents)
* to match the build mode behavior.
*
* Algorithm:
* 1. Check if all dependencies have been inserted
* 2. If not ready, queue this CSS and wait
* 3. If ready, insert CSS after its dependencies in the DOM
* 4. Mark as inserted and process any pending CSS that was waiting for this one
*
* @param id - Unique identifier for the CSS module
* @param content - The CSS content to insert
* @param deps - Array of CSS module IDs this CSS depends on (should load before this)
*/
export function updateStyle(
id: string,
content: string,
deps: string[] = [],
): void {
if (linkSheetsMap.has(id)) return

let style = sheetsMap.get(id)

// Check if we're updating existing style (HMR)
const isUpdate = !!style

if (isUpdate) {
// For HMR updates, just update content and keep position
style!.textContent = content
sheetsMap.set(id, style!)
return
}

// New CSS insertion - check dependencies
const depsReady = deps.every((depId) => insertedCSS.has(depId))

if (!depsReady) {
// Dependencies not ready - queue this CSS for later
// Don't create element yet - it will be created when dependencies are ready
pendingCSS.set(id, { css: content, deps })
return
}

// Dependencies are ready - insert CSS
if (!style) {
style = document.createElement('style')
style.setAttribute('type', 'text/css')
Expand All @@ -530,23 +580,94 @@ export function updateStyle(id: string, content: string): void {
if (cspNonce) {
style.setAttribute('nonce', cspNonce)
}
}

if (!lastInsertedStyle) {
document.head.appendChild(style)

// reset lastInsertedStyle after async
// because dynamically imported css will be split into a different file
setTimeout(() => {
lastInsertedStyle = undefined
}, 0)
} else {
lastInsertedStyle.insertAdjacentElement('afterend', style)
// Find the insertion point - after the last dependency
let insertAfter: HTMLStyleElement | null = null

for (const depId of deps) {
const depStyle = sheetsMap.get(depId)
if (depStyle) {
// Find the last dependency in DOM order
if (
!insertAfter ||
depStyle.compareDocumentPosition(insertAfter) &
Node.DOCUMENT_POSITION_FOLLOWING
) {
insertAfter = depStyle
}
}
lastInsertedStyle = style
}

// Insert the style element based on dependencies
if (insertAfter) {
// Has dependencies - insert right after the last dependency
// For static imports, this maintains proper cascade
// For dynamic imports, dependencies are likely at the end, so this puts it at end too
insertAfter.insertAdjacentElement('afterend', style)
} else if (deps.length > 0) {
// Has dependencies but none found - append to end as fallback
document.head.appendChild(style)
} else if (lastInsertedViteCSS && lastInsertedViteCSS.parentNode) {
// No dependencies - use arrival order
lastInsertedViteCSS.insertAdjacentElement('afterend', style)
} else {
style.textContent = content
// First CSS or reset - append to end
// This ensures it can override any existing styles
document.head.appendChild(style)
}

sheetsMap.set(id, style)
insertedCSS.add(id)

// Track for arrival-order insertion
lastInsertedViteCSS = style

// Reset tracking after async to prevent cross-chunk chaining
if (deps.length === 0) {
setTimeout(() => {
if (lastInsertedViteCSS === style) {
lastInsertedViteCSS = null
}
}, 0)
}

// Process any pending CSS that was waiting for this one
processPendingCSS()
}

/**
* Process pending CSS that may now be ready to insert.
* Called after a CSS file is successfully inserted.
*
* This uses a loop to handle transitive dependencies - CSS that becomes ready
* after we insert CSS that was itself waiting. Without this, we could have
* deadlocks where CSS is stuck waiting forever.
*/
function processPendingCSS(): void {
// Keep processing until no more CSS becomes ready
// This handles chains: A waits for B, B waits for C, C just loaded
let processedAny = true

while (processedAny) {
processedAny = false
const toProcess: Array<[string, { css: string; deps: string[] }]> = []

// Find all pending CSS whose dependencies are now satisfied
for (const [id, { css, deps }] of pendingCSS.entries()) {
const allDepsReady = deps.every((depId) => insertedCSS.has(depId))
if (allDepsReady) {
toProcess.push([id, { css, deps }])
processedAny = true
}
}

// Insert all CSS that became ready in this iteration
for (const [id, { css, deps }] of toProcess) {
pendingCSS.delete(id)
updateStyle(id, css, deps)
}
}
}

export function removeStyle(id: string): void {
Expand All @@ -565,9 +686,15 @@ export function removeStyle(id: string): void {
}
const style = sheetsMap.get(id)
if (style) {
// If we're removing the last inserted CSS, clear the tracker
if (style === lastInsertedViteCSS) {
lastInsertedViteCSS = null
}
document.head.removeChild(style)
sheetsMap.delete(id)
insertedCSS.delete(id)
}
pendingCSS.delete(id)
}

export function createHotContext(ownerPath: string): ViteHotContext {
Expand Down
82 changes: 81 additions & 1 deletion packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,79 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
}
}

/**
* Calculate CSS dependencies for a given CSS module in dev mode.
*
* This function traverses the module graph to find all CSS files that should
* be loaded before the given CSS file, based on the dependency chain of JS modules.
*
* Algorithm:
* 1. Start with the CSS module
* 2. Find all JS modules that import this CSS (importers)
* 3. For each JS importer, look at what other modules it imports
* 4. Collect all CSS files from those imports (these are dependencies)
* 5. Recursively process JS modules to handle transitive dependencies
* 6. Track visited JS modules to prevent infinite loops (circular dependencies)
* 7. Return deduplicated list of CSS dependency IDs in deterministic order
*
* Example:
* async-1.css is imported by async-1.js
* async-1.js also imports base.js
* base.js imports base.css
* → Result: async-1.css depends on base.css
*
* @param cssModuleId - The ID of the CSS module to find dependencies for
* @param moduleGraph - The dev environment module graph
* @returns Array of CSS module IDs that should load before this CSS
*/
function getCssDependencies(
cssModuleId: string,
moduleGraph: import('../server/moduleGraph').EnvironmentModuleGraph,
): string[] {
const cssModule = moduleGraph.getModuleById(cssModuleId)
if (!cssModule) {
return []
}

const cssDeps = new Set<string>()
const visitedJsModules = new Set<EnvironmentModuleNode>()

/**
* Recursively collect CSS dependencies from a JS module's imports
*/
function collectDepsFromJsModule(jsModule: EnvironmentModuleNode) {
// Prevent infinite loops from circular JS dependencies
if (visitedJsModules.has(jsModule)) {
return
}
visitedJsModules.add(jsModule)

// Look at what this JS module imports
for (const imported of jsModule.importedModules) {
if (imported.type === 'css') {
// Found a CSS dependency (but not the original CSS file itself)
if (imported.id && imported.id !== cssModuleId) {
cssDeps.add(imported.id)
}
} else if (imported.type === 'js') {
// Recursively check what this JS module imports
collectDepsFromJsModule(imported)
}
}
}

// Start from all JS modules that import this CSS file
for (const importer of cssModule.importers) {
if (importer.type === 'js') {
collectDepsFromJsModule(importer)
}
}

// Convert Set to Array for deterministic ordering
// Sets preserve insertion order in ES2015+
return Array.from(cssDeps)
}

/**
* Plugin applied after user plugins
*/
Expand Down Expand Up @@ -587,13 +660,20 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
}

const cssContent = await getContentWithSourcemap(css)

// Calculate CSS dependencies for proper ordering in dev mode
// This ensures CSS loads in the same order as build mode
const { moduleGraph } = this.environment as DevEnvironment
const cssDeps = getCssDependencies(id, moduleGraph)

const code = [
`import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from ${JSON.stringify(
path.posix.join(config.base, CLIENT_PUBLIC_PATH),
)}`,
`const __vite__id = ${JSON.stringify(id)}`,
`const __vite__css = ${JSON.stringify(cssContent)}`,
`__vite__updateStyle(__vite__id, __vite__css)`,
`const __vite__deps = ${JSON.stringify(cssDeps)}`,
`__vite__updateStyle(__vite__id, __vite__css, __vite__deps)`,
// css modules exports change on edit so it can't self accept
`${modulesCode || 'import.meta.hot.accept()'}`,
`import.meta.hot.prune(() => __vite__removeStyle(__vite__id))`,
Expand Down
22 changes: 22 additions & 0 deletions playground/css/__tests__/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,28 @@ export const tests = (isLightningCSS: boolean) => {
await expect.poll(() => getColor('.modules-pink')).toBe('pink')
})

// Test for issue #3924: CSS injection order with diamond dependencies
test('async css order with diamond dependencies', async () => {
// Diamond dependency: main -> [chunk-a, chunk-b] -> shared-base
// Expected order: shared-base.css, chunk-a.css, chunk-b.css
// chunk-b.css should win (.diamond-test { color: green; background: yellow })
await expect.poll(() => getColor('.diamond-test')).toBe('green')
await expect.poll(() => getBgColor('.diamond-test')).toBe('yellow')
})

// Test for issue #9278: Shared function with global CSS before module CSS
test('async css order with shared dependency and global CSS', async () => {
// Both blue.js and black.js import make-text.js (shared dependency)
// Both import hotpink.css before their own module CSS
// Expected: hotpink.css should load first, then blue/black module CSS should win
// The elements have both .hotpink and their module class
const blueEl = await page.locator('text=async blue').first()
const blackEl = await page.locator('text=async black').first()

await expect.poll(() => getColor(blueEl)).toBe('blue')
await expect.poll(() => getColor(blackEl)).toBe('black')
})

test('@import scss', async () => {
expect(await getColor('.at-import-scss')).toBe('red')
})
Expand Down
5 changes: 5 additions & 0 deletions playground/css/async/black.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { makeText } from './make-text'
import './hotpink.css'
import styles from './black.module.css'

makeText(styles['black-module'], 'async black')
3 changes: 3 additions & 0 deletions playground/css/async/black.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.black-module {
color: black;
}
5 changes: 5 additions & 0 deletions playground/css/async/blue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { makeText } from './make-text'
import './hotpink.css'
import styles from './blue.module.css'

makeText(styles['blue-module'], 'async blue')
3 changes: 3 additions & 0 deletions playground/css/async/blue.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.blue-module {
color: blue;
}
5 changes: 5 additions & 0 deletions playground/css/async/chunk-a.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* Chunk A depends on shared-base */
/* This should come AFTER shared-base.css */
.diamond-test {
color: blue;
}
8 changes: 8 additions & 0 deletions playground/css/async/chunk-a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { initSharedBase } from './shared-base'
import './chunk-a.css'

initSharedBase()

export function initChunkA() {
console.log('[chunk-a] initialized')
}
6 changes: 6 additions & 0 deletions playground/css/async/chunk-b.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* Chunk B also depends on shared-base */
/* This should come AFTER shared-base.css AND chunk-a.css */
.diamond-test {
color: green;
background: yellow;
}
16 changes: 16 additions & 0 deletions playground/css/async/chunk-b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { initSharedBase } from './shared-base'
import { initChunkA } from './chunk-a'
import './chunk-b.css'

initSharedBase()
initChunkA()

export function initChunkB() {
console.log('[chunk-b] initialized')

// Create test element
const div = document.createElement('div')
div.className = 'diamond-test'
div.textContent = 'Diamond Dependency Test'
document.body.appendChild(div)
}
16 changes: 16 additions & 0 deletions playground/css/async/diamond.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// This creates a diamond dependency:
// main (this file)
// -> chunk-a -> shared-base
// -> chunk-b -> shared-base
// -> chunk-a -> shared-base
//
// Expected CSS order: shared-base.css, chunk-a.css, chunk-b.css
// Expected final color: green (from chunk-b)
// Expected final background: yellow (from chunk-b)

Promise.all([import('./chunk-a.js'), import('./chunk-b.js')]).then(
([modA, modB]) => {
modA.initChunkA()
modB.initChunkB()
},
)
Loading