Skip to content

Conversation

@Auankj
Copy link

@Auankj Auankj commented Nov 6, 2025

Description

Fixes #3924 - CSS injection order with dynamic imports

Problem

In dev mode, CSS from dynamic imports was being inserted in the wrong order, causing:

  • CSS cascade broken vs build mode (4+ year old issue)
  • Race conditions with parallel dynamic imports
  • Diamond dependencies loading incorrectly
  • Transitive CSS dependencies failing
  • Manual DOM manipulation scenarios broken

Impact: Styles would override incorrectly, dev mode behavior didn't match production

Solution

Implemented comprehensive dependency-aware CSS insertion system:

Server-side (packages/vite/src/node/plugins/css.ts):

  • Added getCssDependencies() function that traverses the module graph
  • Calculates CSS dependencies based on JS import chains
  • Handles transitive dependencies recursively
  • Returns ordered array of CSS module IDs

Client-side (packages/vite/src/client/client.ts):

  • Modified updateStyle() to accept deps parameter (dependency array)
  • Inserts CSS after its dependencies to respect cascade order
  • Uses appendChild fallback to properly override user styles
  • Maintains arrival-order tracking with setTimeout reset
  • Added processPendingCSS() to handle transitive dependency chains
  • Added proper cleanup in removeStyle()

Algorithm

Server calculates dependencies:

  1. Start with CSS module
  2. Find all JS modules that import it
  3. For each JS module, collect CSS files it imports
  4. Recursively traverse to handle transitive deps
  5. Return deduplicated, ordered list

Client inserts with dependencies:

  1. Check if all dependencies have been inserted
  2. If not ready, queue CSS and wait
  3. When ready, insert after last dependency in DOM
  4. Process any pending CSS that was waiting
  5. Track for arrival-order insertion (setTimeout reset)

Test Results

Before: 133/134 tests passing (99.3%)
After: 134/134 tests passing (100%)

Previously Failing Test:

  • ✅ "style order should be consistent when style tag is inserted by JS" (css-codesplit)

Added Test Coverage:

  • ✅ Diamond dependency test case in playground/css

Test Cases Verified

✅ Diamond dependencies (A→B, A→C, B→D, C→D)
✅ Parallel dynamic imports
✅ CSS with JS wrappers
✅ Transitive dependencies (3+ levels deep)
✅ Direct dynamic CSS imports
✅ Manual DOM manipulation with style tags
✅ HMR updates (preserve position)

Breaking Changes

None - this is a bug fix that aligns dev mode with build mode behavior.

Checklist

  • All tests passing (134/134 CSS tests)
  • Lint passing
  • Format passing
  • Added test case for diamond dependencies
  • No regressions
  • Comprehensive solution (not a patch work)

Related Issues

Closes #3924

@Auankj
Copy link
Author

Auankj commented Nov 6, 2025

Additional Technical Details

Why This Fix Works

The previous implementation used setTimeout(0) to reset insertion tracking, which failed with parallel dynamic imports because:

  1. Multiple CSS files could arrive simultaneously
  2. Tracking would reset before all related CSS loaded
  3. No dependency information was passed to the client

This fix resolves that by:

  1. Server calculates dependencies via module graph traversal
  2. Client respects dependencies by waiting and inserting in correct order
  3. Handles transitive deps through recursive processing

Example: Diamond Dependency

main.js
  ├─> chunk-a.js ──> shared-base.js ──> shared-base.css
  └─> chunk-b.js ──> shared-base.js ──> shared-base.css
                 └─> chunk-a.js ──> shared-base.js ──> shared-base.css

Before Fix:

  • CSS loaded in race condition order
  • chunk-b.css might load before shared-base.css
  • Wrong cascade order

After Fix:

  1. Server detects: chunk-b.css depends on shared-base.css and chunk-a.css
  2. Client waits until both deps are inserted
  3. Inserts chunk-b.css after dependencies
  4. Correct cascade: shared-base.csschunk-a.csschunk-b.css

Performance Impact

  • Minimal overhead: Only tracks CSS modules, not executed on every module
  • No blocking: CSS queuing is async, doesn't block page load
  • HMR optimized: Updates preserve position (no re-insertion)

Test Evidence

All CSS test suites passing:

Test Files: 13 passed | 5 skipped (18)
Tests: 134 passed | 37 skipped (171)

@Auankj Auankj changed the title fix: CSS injection order with dynamic imports (#3924) fix: css injection order with dynamic imports (#3924) Nov 6, 2025
@Auankj
Copy link
Author

Auankj commented Nov 6, 2025

Fixes Applied

I've addressed the feedback from the automated checks:

1. Fixed PR Title

Changed from:

fix: CSS injection order with dynamic imports (#3924)

To:

fix: css injection order with dynamic imports (#3924)
  • Subject now starts with lowercase as per semantic PR requirements

2. Removed Duplicate JSDoc Comment

  • Removed the duplicate JSDoc block for processPendingCSS() function (lines 660-667)
  • Kept only the single, clean JSDoc comment
  • Fixed trailing spaces in comments

Commit: 35184c6

Both issues should now be resolved!

@Auankj
Copy link
Author

Auankj commented Nov 6, 2025

Performance Optimization Applied

Thanks to the excellent review from graphite-app bot! Fixed an inefficiency in the pending CSS queue:

Problem

When CSS was queued for pending dependencies, we were creating a style element that was never used:

// Old code - wasteful
if (!depsReady) {
  style = document.createElement('style')  // Created but never inserted
  style.setAttribute('type', 'text/css')
  style.setAttribute('data-vite-dev-id', id)
  style.textContent = content
  pendingCSS.set(id, { css, deps, element: style })  // Stored but discarded
  return
}

When processPendingCSS() later called updateStyle() again, a new style element was created because sheetsMap.get(id) returned undefined (the queued element was never added to the map).

Solution

Don't create the style element when queuing - just store the data:

// New code - efficient
if (!depsReady) {
  pendingCSS.set(id, { css: content, deps })  // Just data, no DOM element
  return
}

The element is now only created once, when dependencies are actually ready and the CSS is inserted.

Benefits

  • ✅ Eliminates unnecessary DOM element creation
  • ✅ Reduces garbage collection pressure
  • ✅ Cleaner, simpler code
  • ✅ Same functionality, better performance

Commit: 84f1e2d

@IanVS
Copy link
Contributor

IanVS commented Nov 7, 2025

I wonder if you could try out the test cases in #9278, to see if they are addressed here.

@Auankj
Copy link
Author

Auankj commented Nov 7, 2025

@IanVS Good idea! I'll create a test case based on that scenario - two async chunks that share a common dependency, both importing the same global CSS before their own module CSS.

Let me add this test case now to verify our fix handles it correctly.

@Auankj
Copy link
Author

Auankj commented Nov 7, 2025

@IanVS I've added a comprehensive test case for the scenario from issue #9278 in commit 53e18e3.

Test Case Structure

The test validates the shared dependency scenario where:

  • Two async chunks (blue.js and black.js) both import a shared utility (make-text.js)
  • Both chunks import the same global CSS (hotpink.css) before their module CSS files
  • Each chunk then imports its own module CSS (blue.module.css and black.module.css)

What the Test Validates

The test ensures that module CSS correctly takes precedence over global CSS in the cascade order:

test('async css order with shared dependency and global CSS', async () => {
  const blueEl = page.locator('text=async blue').first()
  const blackEl = page.locator('text=async black').first()

  // Module CSS should win over global CSS
  await expect.poll(() => getColor(blueEl)).toBe('blue')
  await expect.poll(() => getColor(blackEl)).toBe('black')
})

Even though both elements have the hotpink class from global CSS, the module CSS colors (blue and black) correctly take precedence, matching the behavior in build mode.

All tests passing (945 total)

aunak added 4 commits November 7, 2025 18:48
Fixes issue where CSS from dynamic imports loaded in wrong order in dev mode,
causing cascade issues and inconsistency with build mode.

Problem:
- CSS from dynamic imports inserted in arbitrary order (race conditions)
- Diamond dependencies loaded incorrectly
- Parallel dynamic imports failed
- Dev mode behavior didn't match build mode

Solution:
Server-side (css.ts):
- Added getCssDependencies() to calculate CSS deps via module graph traversal
- Handles transitive dependencies recursively
- Returns ordered list of CSS dependencies

Client-side (client.ts):
- Modified updateStyle() to accept dependency array
- Inserts CSS after dependencies to respect cascade order
- Uses appendChild fallback for proper override behavior
- Maintains arrival-order tracking with setTimeout reset
- Added processPendingCSS() to handle transitive dependencies

Test Results:
- Before: 133/134 passing (99.3%)
- After: 134/134 passing (100%) ✅
- Added diamond dependency test case

All test suites passing, no regressions.
- Remove duplicate JSDoc block for processPendingCSS function
- Fix trailing spaces in JSDoc comments
- Don't create style element when queueing CSS for pending dependencies
- Element will be created when dependencies are ready and CSS is actually inserted
- Removes unnecessary DOM element creation and garbage collection
- Fixes Graphite bot review feedback
…js#9278)

This test validates the scenario from issue vitejs#9278 where two async chunks
(blue.js and black.js) share a common dependency (make-text.js), and both
import global CSS (hotpink.css) before their module CSS files.

The test ensures that module CSS correctly takes precedence over global CSS
in the cascade order, matching the behavior of build mode.

Addresses feedback from @IanVS
@Auankj Auankj force-pushed the fix/css-injection-order-3924 branch from 53e18e3 to 2711e69 Compare November 7, 2025 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vite injects css assets in wrong order with dynamic import and css modules.

3 participants