Skip to content

Commit 687c38b

Browse files
shYkiStobluwy
andauthored
fix(ssr): resolve interlocking circular dependency issues (#15395)
Co-authored-by: bluwy <[email protected]>
1 parent 67ff94b commit 687c38b

File tree

9 files changed

+137
-50
lines changed

9 files changed

+137
-50
lines changed

packages/vite/src/node/server/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -563,13 +563,7 @@ export async function _createServer(
563563
return devHtmlTransformFn(server, url, html, originalUrl)
564564
},
565565
async ssrLoadModule(url, opts?: { fixStacktrace?: boolean }) {
566-
return ssrLoadModule(
567-
url,
568-
server,
569-
undefined,
570-
undefined,
571-
opts?.fixStacktrace,
572-
)
566+
return ssrLoadModule(url, server, undefined, opts?.fixStacktrace)
573567
},
574568
async ssrFetchModule(url: string, importer?: string) {
575569
return ssrFetchModule(server, url, importer)

packages/vite/src/node/ssr/ssrModuleLoader.ts

Lines changed: 66 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,13 @@ interface NodeImportResolveOptions
3939
}
4040

4141
const pendingModules = new Map<string, Promise<SSRModule>>()
42-
const pendingImports = new Map<string, string[]>()
42+
const pendingModuleDependencyGraph = new Map<string, Set<string>>()
4343
const importErrors = new WeakMap<Error, { importee: string }>()
4444

4545
export async function ssrLoadModule(
4646
url: string,
4747
server: ViteDevServer,
4848
context: SSRContext = { global },
49-
urlStack: string[] = [],
5049
fixStacktrace?: boolean,
5150
): Promise<SSRModule> {
5251
url = unwrapId(url)
@@ -60,17 +59,11 @@ export async function ssrLoadModule(
6059
return pending
6160
}
6261

63-
const modulePromise = instantiateModule(
64-
url,
65-
server,
66-
context,
67-
urlStack,
68-
fixStacktrace,
69-
)
62+
const modulePromise = instantiateModule(url, server, context, fixStacktrace)
7063
pendingModules.set(url, modulePromise)
7164
modulePromise
7265
.catch(() => {
73-
pendingImports.delete(url)
66+
/* prevent unhandled promise rejection error from bubbling up */
7467
})
7568
.finally(() => {
7669
pendingModules.delete(url)
@@ -82,7 +75,6 @@ async function instantiateModule(
8275
url: string,
8376
server: ViteDevServer,
8477
context: SSRContext = { global },
85-
urlStack: string[] = [],
8678
fixStacktrace?: boolean,
8779
): Promise<SSRModule> {
8880
const { moduleGraph } = server
@@ -122,9 +114,6 @@ async function instantiateModule(
122114
url: pathToFileURL(mod.file!).toString(),
123115
}
124116

125-
urlStack = urlStack.concat(url)
126-
const isCircular = (url: string) => urlStack.includes(url)
127-
128117
const {
129118
isProduction,
130119
resolve: { dedupe, preserveSymlinks },
@@ -150,38 +139,37 @@ async function instantiateModule(
150139
packageCache: server.config.packageCache,
151140
}
152141

153-
// Since dynamic imports can happen in parallel, we need to
154-
// account for multiple pending deps and duplicate imports.
155-
const pendingDeps: string[] = []
156-
157142
const ssrImport = async (dep: string, metadata?: SSRImportBaseMetadata) => {
158143
try {
159144
if (dep[0] !== '.' && dep[0] !== '/') {
160145
return await nodeImport(dep, mod.file!, resolveOptions, metadata)
161146
}
162147
// convert to rollup URL because `pendingImports`, `moduleGraph.urlToModuleMap` requires that
163148
dep = unwrapId(dep)
164-
if (!isCircular(dep) && !pendingImports.get(dep)?.some(isCircular)) {
165-
pendingDeps.push(dep)
166-
if (pendingDeps.length === 1) {
167-
pendingImports.set(url, pendingDeps)
168-
}
169-
const mod = await ssrLoadModule(
170-
dep,
171-
server,
172-
context,
173-
urlStack,
174-
fixStacktrace,
175-
)
176-
if (pendingDeps.length === 1) {
177-
pendingImports.delete(url)
178-
} else {
179-
pendingDeps.splice(pendingDeps.indexOf(dep), 1)
149+
150+
// Handle any potential circular dependencies for static imports, preventing
151+
// deadlock scenarios when two modules are indirectly waiting on one another
152+
// to finish initializing. Dynamic imports are resolved at runtime, hence do
153+
// not contribute to the static module dependency graph in the same way
154+
if (!metadata?.isDynamicImport) {
155+
addPendingModuleDependency(url, dep)
156+
157+
// If there's a circular dependency formed as a result of the dep import,
158+
// return the current state of the dependent module being initialized, in
159+
// order to avoid interlocking circular dependencies hanging indefinitely
160+
if (checkModuleDependencyExists(dep, url)) {
161+
const depSsrModule = moduleGraph.urlToModuleMap.get(dep)?.ssrModule
162+
if (!depSsrModule) {
163+
// Technically, this should never happen under normal circumstances
164+
throw new Error(
165+
'[vite] The dependency module is not yet fully initialized due to circular dependency. This is a bug in Vite SSR',
166+
)
167+
}
168+
return depSsrModule
180169
}
181-
// return local module to avoid race condition #5470
182-
return mod
183170
}
184-
return moduleGraph.urlToModuleMap.get(dep)?.ssrModule
171+
172+
return ssrLoadModule(dep, server, context, fixStacktrace)
185173
} catch (err) {
186174
// tell external error handler which mod was imported with error
187175
importErrors.set(err, { importee: dep })
@@ -267,11 +255,52 @@ async function instantiateModule(
267255
)
268256

269257
throw e
258+
} finally {
259+
pendingModuleDependencyGraph.delete(url)
270260
}
271261

272262
return Object.freeze(ssrModule)
273263
}
274264

265+
function addPendingModuleDependency(originUrl: string, depUrl: string): void {
266+
if (pendingModuleDependencyGraph.has(originUrl)) {
267+
pendingModuleDependencyGraph.get(originUrl)!.add(depUrl)
268+
} else {
269+
pendingModuleDependencyGraph.set(originUrl, new Set([depUrl]))
270+
}
271+
}
272+
273+
function checkModuleDependencyExists(
274+
originUrl: string,
275+
targetUrl: string,
276+
): boolean {
277+
const visited = new Set()
278+
const stack = [originUrl]
279+
280+
while (stack.length) {
281+
const currentUrl = stack.pop()!
282+
283+
if (currentUrl === targetUrl) {
284+
return true
285+
}
286+
287+
if (!visited.has(currentUrl)) {
288+
visited.add(currentUrl)
289+
290+
const dependencies = pendingModuleDependencyGraph.get(currentUrl)
291+
if (dependencies) {
292+
for (const depUrl of dependencies) {
293+
if (!visited.has(depUrl)) {
294+
stack.push(depUrl)
295+
}
296+
}
297+
}
298+
}
299+
}
300+
301+
return false
302+
}
303+
275304
// In node@12+ we can use dynamic import to load CJS and ESM
276305
async function nodeImport(
277306
id: string,

playground/ssr/__tests__/ssr.spec.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,20 @@ test(`circular import doesn't throw`, async () => {
2020
)
2121
})
2222

23-
test(`deadlock doesn't happen`, async () => {
24-
await page.goto(`${url}/forked-deadlock`)
23+
test(`deadlock doesn't happen for static imports`, async () => {
24+
await page.goto(`${url}/forked-deadlock-static-imports`)
2525

26-
expect(await page.textContent('.forked-deadlock')).toMatch('rendered')
26+
expect(await page.textContent('.forked-deadlock-static-imports')).toMatch(
27+
'rendered',
28+
)
29+
})
30+
31+
test(`deadlock doesn't happen for dynamic imports`, async () => {
32+
await page.goto(`${url}/forked-deadlock-dynamic-imports`)
33+
34+
expect(await page.textContent('.forked-deadlock-dynamic-imports')).toMatch(
35+
'rendered',
36+
)
2737
})
2838

2939
test('should restart ssr', async () => {

playground/ssr/src/app.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ const pathRenderers = {
44
'/': renderRoot,
55
'/circular-dep': renderCircularDep,
66
'/circular-import': renderCircularImport,
7-
'/forked-deadlock': renderForkedDeadlock,
7+
'/forked-deadlock-static-imports': renderForkedDeadlockStaticImports,
8+
'/forked-deadlock-dynamic-imports': renderForkedDeadlockDynamicImports,
89
}
910

1011
export async function render(url, rootDir) {
@@ -40,8 +41,16 @@ async function renderCircularImport(rootDir) {
4041
return `<div class="circ-import">${escapeHtml(logA())}</div>`
4142
}
4243

43-
async function renderForkedDeadlock(rootDir) {
44+
async function renderForkedDeadlockStaticImports(rootDir) {
4445
const { commonModuleExport } = await import('./forked-deadlock/common-module')
4546
commonModuleExport()
46-
return `<div class="forked-deadlock">rendered</div>`
47+
return `<div class="forked-deadlock-static-imports">rendered</div>`
48+
}
49+
50+
async function renderForkedDeadlockDynamicImports(rootDir) {
51+
const { commonModuleExport } = await import(
52+
'./forked-deadlock/dynamic-imports/common-module'
53+
)
54+
await commonModuleExport()
55+
return `<div class="forked-deadlock-dynamic-imports">rendered</div>`
4756
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* module H
3+
*/
4+
export async function commonModuleExport() {
5+
const [{ stuckModuleExport }, { deadlockfuseModuleExport }] =
6+
await Promise.all([
7+
import('./stuck-module'),
8+
import('./deadlock-fuse-module'),
9+
])
10+
11+
stuckModuleExport()
12+
deadlockfuseModuleExport()
13+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { fuseStuckBridgeModuleExport } from './fuse-stuck-bridge-module'
2+
3+
/**
4+
* module A
5+
*/
6+
export function deadlockfuseModuleExport() {
7+
fuseStuckBridgeModuleExport()
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { stuckModuleExport } from './stuck-module'
2+
3+
/**
4+
* module C
5+
*/
6+
export function fuseStuckBridgeModuleExport() {
7+
stuckModuleExport()
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { deadlockfuseModuleExport } from './deadlock-fuse-module'
2+
3+
/**
4+
* module Y
5+
*/
6+
export function middleModuleExport() {
7+
void deadlockfuseModuleExport
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { middleModuleExport } from './middle-module'
2+
3+
/**
4+
* module X
5+
*/
6+
export function stuckModuleExport() {
7+
middleModuleExport()
8+
}

0 commit comments

Comments
 (0)