Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { beforeAll, describe, expect, test } from 'vitest'
import { init, parse as parseImports } from 'es-module-lexer'
import {
matchImportsToPreloadMarkers,
preloadMarker,
} from '../../plugins/importAnalysisBuild'

beforeAll(async () => {
await init
})

// the start position of every `__VITE_PRELOAD__` marker, in source order
function markerPositions(code: string): number[] {
const positions: number[] = []
for (
let pos = code.indexOf(preloadMarker);
pos !== -1;
pos = code.indexOf(preloadMarker, pos + preloadMarker.length)
) {
positions.push(pos)
}
return positions
}

// pair dynamic imports with markers the way the plugin does (see `generateBundle`)
function match(code: string) {
const imports = parseImports(code)[0].filter((i) => i.d > -1)
return matchImportsToPreloadMarkers(code, imports)
}

describe('matchImportsToPreloadMarkers', () => {
test('returns an empty array when there are no imports', () => {
expect(matchImportsToPreloadMarkers('const a = 1', [])).toStrictEqual([])
})

test('pairs a single dynamic import with its marker', () => {
const code = `__vitePreload(() => import('a'), ${preloadMarker})`
const [marker] = markerPositions(code)
expect(match(code)).toStrictEqual([marker])
})

test('gives sibling imports their own markers (Rollup-era interleaved shape)', () => {
// markers stay interleaved: import a < Ma < import b < Mb
const code =
`__vitePreload(() => import('a'), ${preloadMarker})` +
`.then(() => __vitePreload(() => import('b'), ${preloadMarker}))`
const [markerA, markerB] = markerPositions(code)
expect(match(code)).toStrictEqual([markerA, markerB])
})

test('gives a nested `import().then(() => import())` each its own marker (#22700)', () => {
// Rolldown wraps the whole `.then`, so the inner marker comes before the outer one:
// the outer import must NOT be paired with the inner marker it textually precedes.
const code =
`__vitePreload(() => import('a').then(() => ` +
`__vitePreload(() => import('b'), ${preloadMarker})), ${preloadMarker})`
const [innerMarker, outerMarker] = markerPositions(code)
// source order: [0] = import('a') (outer), [1] = import('b') (inner)
expect(match(code)).toStrictEqual([outerMarker, innerMarker])
})

test('#3051: a lone import whose marker precedes it still pairs with that marker', () => {
const code = `const x = ${preloadMarker};import('a')`
const [marker] = markerPositions(code)
expect(match(code)).toStrictEqual([marker])
})
})
57 changes: 52 additions & 5 deletions packages/vite/src/node/plugins/importAnalysisBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,55 @@ function findPreloadMarker(str: string, pos: number = 0): number {
return result?.index ?? -1
}

/**
* Pairs each dynamic `import()` with the `__VITE_PRELOAD__` marker that belongs to it.
*
* Each import is wrapped as `__vitePreload(factory, __VITE_PRELOAD__)`, so its marker
* comes right after it. Nested imports nest the wrappers, so the imports and markers
* form balanced brackets. For example, in `import('a').then(() => import('b'))`, b's
* marker ends up before a's. Pairing each import with the next marker would give b's
* marker to both imports and drop a's CSS to `void 0` (#22700). Instead the markers are
* matched with a stack in one pass, and each marker closes the innermost import that is
* still open.
*
* The returned array is parallel to `imports`. Entry `i` holds the start position of
* import `i`'s marker, or -1 when the import has no marker.
*/
export function matchImportsToPreloadMarkers(
code: string,
imports: readonly ImportSpecifier[],
): number[] {
const importMarkerPos = new Array<number>(imports.length).fill(-1)
if (imports.length === 0) return importMarkerPos

const openImports: number[] = []
let nextImport = 0
for (
let markerStartPos = findPreloadMarker(code, imports[0].e);
markerStartPos !== -1;
markerStartPos = findPreloadMarker(
code,
markerStartPos + preloadMarker.length,
)
) {
while (
nextImport < imports.length &&
imports[nextImport].e <= markerStartPos
) {
openImports.push(nextImport++)
}
if (openImports.length) {
importMarkerPos[openImports.pop()!] = markerStartPos
}
}
// #3051: a lone import whose marker isn't placed after it pairs with the only marker
if (imports.length === 1 && importMarkerPos[0] === -1) {
importMarkerPos[0] = findPreloadMarker(code)
}

return importMarkerPos
}

/**
* Helper for preloading CSS and direct imports of async chunks in parallel to
* the async chunk itself.
Expand Down Expand Up @@ -369,6 +418,8 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin[] {
}

if (imports.length) {
const importMarkerPos = matchImportsToPreloadMarkers(code, imports)

for (let index = 0; index < imports.length; index++) {
// To handle escape sequences in specifier strings, the .n field will be provided where possible.
const {
Expand Down Expand Up @@ -438,11 +489,7 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin[] {
addDeps(normalizedFile)
}

let markerStartPos = findPreloadMarker(code, end)
// fix issue #3051
if (markerStartPos === -1 && imports.length === 1) {
markerStartPos = findPreloadMarker(code)
}
const markerStartPos = importMarkerPos[index]

if (markerStartPos > 0) {
// the dep list includes the main chunk, so only need to reload when there are actual other deps.
Expand Down
13 changes: 13 additions & 0 deletions playground/dynamic-import/__tests__/dynamic-import.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ test('should work with load ../ and contain itself directory', async () => {
.toMatch('dynamic-import-nested-self-content')
})

// #22700: nested `import('a').then(() => import('b'))` where `a` has a CSS
// side-effect dep — the outer import's CSS must still be loaded in build output
test('should load css of nested dynamic import', async () => {
await expect
.poll(() => page.textContent('.then-css-outer'))
.toMatch('then-css-outer')
await expect.poll(() => getColor('.then-css-outer')).toBe('red')
await expect
.poll(() => page.textContent('.then-css-inner'))
.toMatch('then-css-inner')
await expect.poll(() => getColor('.then-css-inner')).toBe('green')
})

test('should work a load path that contains parentheses.', async () => {
await expect
.poll(() =>
Expand Down
3 changes: 3 additions & 0 deletions playground/dynamic-import/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@

<div class="dynamic-import-nested-self"></div>

<div class="then-css-outer">todo</div>
<div class="then-css-inner">todo</div>

<script type="module" src="./nested/index.js"></script>
<script type="module" src="./(app)/nest/index.js"></script>
<style>
Expand Down
9 changes: 9 additions & 0 deletions playground/dynamic-import/nested/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,13 @@ import(`../nested/static.js`).then((mod) => {
text('.dynamic-import-static', mod.self)
})

// #22700: in a nested `import().then(() => import())`, the outer import's CSS
// dep used to be dropped to `void 0` in the build output, orphaning the CSS.
import('./then-css/outer.js').then((outerMod) => {
text('.then-css-outer', outerMod.outer)
return import('./then-css/inner.js').then((innerMod) => {
text('.then-css-inner', innerMod.inner)
})
})

console.log('index.js')
3 changes: 3 additions & 0 deletions playground/dynamic-import/nested/then-css/inner.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.then-css-inner {
color: #008000;
}
2 changes: 2 additions & 0 deletions playground/dynamic-import/nested/then-css/inner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import './inner.css'
export const inner = 'then-css-inner'
3 changes: 3 additions & 0 deletions playground/dynamic-import/nested/then-css/outer.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.then-css-outer {
color: #ff0000;
}
2 changes: 2 additions & 0 deletions playground/dynamic-import/nested/then-css/outer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import './outer.css'
export const outer = 'then-css-outer'
Loading