diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 1fc8c2b522fdb3..6e6694ec7b85dd 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -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() +// Queue for CSS files waiting for their dependencies +const pendingCSS = new Map() -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') @@ -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 { @@ -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 { diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index b80696ae6e4784..44e70d902c4020 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -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() + const visitedJsModules = new Set() + + /** + * 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 */ @@ -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))`, diff --git a/playground/css/__tests__/tests.ts b/playground/css/__tests__/tests.ts index 68ff600a39c002..42518e1cbeaf32 100644 --- a/playground/css/__tests__/tests.ts +++ b/playground/css/__tests__/tests.ts @@ -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') }) diff --git a/playground/css/async/black.js b/playground/css/async/black.js new file mode 100644 index 00000000000000..8d72db8b2f0e63 --- /dev/null +++ b/playground/css/async/black.js @@ -0,0 +1,5 @@ +import { makeText } from './make-text' +import './hotpink.css' +import styles from './black.module.css' + +makeText(styles['black-module'], 'async black') diff --git a/playground/css/async/black.module.css b/playground/css/async/black.module.css new file mode 100644 index 00000000000000..8a609488859d8a --- /dev/null +++ b/playground/css/async/black.module.css @@ -0,0 +1,3 @@ +.black-module { + color: black; +} diff --git a/playground/css/async/blue.js b/playground/css/async/blue.js new file mode 100644 index 00000000000000..a0fc833168cb9b --- /dev/null +++ b/playground/css/async/blue.js @@ -0,0 +1,5 @@ +import { makeText } from './make-text' +import './hotpink.css' +import styles from './blue.module.css' + +makeText(styles['blue-module'], 'async blue') diff --git a/playground/css/async/blue.module.css b/playground/css/async/blue.module.css new file mode 100644 index 00000000000000..cdbea06a0ba2b0 --- /dev/null +++ b/playground/css/async/blue.module.css @@ -0,0 +1,3 @@ +.blue-module { + color: blue; +} diff --git a/playground/css/async/chunk-a.css b/playground/css/async/chunk-a.css new file mode 100644 index 00000000000000..b39fe617d748dd --- /dev/null +++ b/playground/css/async/chunk-a.css @@ -0,0 +1,5 @@ +/* Chunk A depends on shared-base */ +/* This should come AFTER shared-base.css */ +.diamond-test { + color: blue; +} diff --git a/playground/css/async/chunk-a.js b/playground/css/async/chunk-a.js new file mode 100644 index 00000000000000..fda6d621c7e006 --- /dev/null +++ b/playground/css/async/chunk-a.js @@ -0,0 +1,8 @@ +import { initSharedBase } from './shared-base' +import './chunk-a.css' + +initSharedBase() + +export function initChunkA() { + console.log('[chunk-a] initialized') +} diff --git a/playground/css/async/chunk-b.css b/playground/css/async/chunk-b.css new file mode 100644 index 00000000000000..8e1c4d3db45388 --- /dev/null +++ b/playground/css/async/chunk-b.css @@ -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; +} diff --git a/playground/css/async/chunk-b.js b/playground/css/async/chunk-b.js new file mode 100644 index 00000000000000..28aedcfb5830a3 --- /dev/null +++ b/playground/css/async/chunk-b.js @@ -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) +} diff --git a/playground/css/async/diamond.js b/playground/css/async/diamond.js new file mode 100644 index 00000000000000..3d775bbf5cbcf0 --- /dev/null +++ b/playground/css/async/diamond.js @@ -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() + }, +) diff --git a/playground/css/async/hotpink.css b/playground/css/async/hotpink.css new file mode 100644 index 00000000000000..a79d1ee0de1f48 --- /dev/null +++ b/playground/css/async/hotpink.css @@ -0,0 +1,3 @@ +.hotpink { + color: hotpink; +} diff --git a/playground/css/async/index.js b/playground/css/async/index.js index 20d6975ab9d23a..5d2719c160ae9d 100644 --- a/playground/css/async/index.js +++ b/playground/css/async/index.js @@ -1,3 +1,6 @@ import('./async-1.js') import('./async-2.js') import('./async-3.js') +import('./diamond.js') +import('./blue.js') +import('./black.js') diff --git a/playground/css/async/make-text.js b/playground/css/async/make-text.js new file mode 100644 index 00000000000000..dfd84ce8d7552c --- /dev/null +++ b/playground/css/async/make-text.js @@ -0,0 +1,6 @@ +export function makeText(className, content) { + const div = document.createElement('div') + div.className = `base hotpink ${className}` + document.body.appendChild(div) + div.textContent = `${content} ${getComputedStyle(div).color}` +} diff --git a/playground/css/async/shared-base.css b/playground/css/async/shared-base.css new file mode 100644 index 00000000000000..7351de2570eaa6 --- /dev/null +++ b/playground/css/async/shared-base.css @@ -0,0 +1,6 @@ +/* This CSS is imported by both chunk-a and chunk-b */ +/* In correct order, chunk-b's CSS should override this */ +.diamond-test { + color: red; + background: black; +} diff --git a/playground/css/async/shared-base.js b/playground/css/async/shared-base.js new file mode 100644 index 00000000000000..ab07f9f5af342d --- /dev/null +++ b/playground/css/async/shared-base.js @@ -0,0 +1,5 @@ +import './shared-base.css' + +export function initSharedBase() { + console.log('[shared-base] initialized') +}