Skip to content

Commit af903c6

Browse files
hi-ogawaclaude
andauthored
fix(ui): store module graph once per environment (#11421)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a627be4 commit af903c6

7 files changed

Lines changed: 288 additions & 183 deletions

File tree

‎packages/ui/client/components/FileDetailsModuleGraph.vue‎

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,16 @@ const props = defineProps<{
1212
}>()
1313
1414
const hideNodeModules = ref(true)
15-
const NODE_MODULES_RE = /[/\\]node_modules[/\\]/
1615
1716
const { state: graphData, isLoading } = useAsyncState(
1817
() =>
1918
client.rpc.getModuleGraph(props.projectName, props.file.filepath, props.file.viteEnvironment),
2019
undefined,
2120
)
2221
23-
const graph = computed(() => {
24-
if (!graphData.value) {
25-
return { nodes: [], links: [] }
26-
}
27-
let moduleGraph = graphData.value
28-
if (hideNodeModules.value) {
29-
moduleGraph = {
30-
...moduleGraph,
31-
inlined: moduleGraph.inlined.filter((n) => !NODE_MODULES_RE.test(n)),
32-
externalized: moduleGraph.externalized.filter((n) => !NODE_MODULES_RE.test(n)),
33-
}
34-
}
35-
return getModuleGraph(moduleGraph, props.file.filepath)
36-
})
22+
const graph = computed(() =>
23+
getModuleGraph(graphData.value, props.file.filepath, hideNodeModules.value),
24+
)
3725
</script>
3826

3927
<template>

‎packages/ui/client/composables/client/static.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { ModuleGraphData, RunnerTestFile, SerializedRootConfig } from 'vitest'
1+
import type { RunnerTestFile, SerializedRootConfig } from 'vitest'
2+
import type { SharedModuleGraphByProject } from '../../../../vitest/src/types/general'
23
import type { VitestClient, VitestClientRpc } from './ws'
34
import { decompressSync, strFromU8 } from 'fflate'
45
import { parse } from 'flatted'
@@ -8,7 +9,7 @@ import { StateManager } from './state'
89
export interface HTMLReportMetadata {
910
files: RunnerTestFile[]
1011
config: SerializedRootConfig
11-
moduleGraph: Record<string, Record<string, ModuleGraphData>>
12+
moduleGraph: SharedModuleGraphByProject
1213
unhandledErrors: unknown[]
1314
testModules: {
1415
projectName: string
@@ -38,8 +39,13 @@ function deserializeReportMetadata(metadata: HTMLReportMetadata) {
3839
getConfig: async () => {
3940
return metadata.config
4041
},
41-
getModuleGraph: async (projectName, id) => {
42-
return metadata.moduleGraph[projectName]?.[id]
42+
getModuleGraph: async (projectName, id, viteEnvironment) => {
43+
// the reporter keys graphs by the environment the file ran in, which is the file's
44+
// `viteEnvironment`. It's unset only for files that never ran, which have no graph.
45+
const graph = viteEnvironment
46+
? metadata.moduleGraph[projectName]?.[viteEnvironment]
47+
: undefined
48+
return { modules: graph?.modules ?? {}, roots: graph?.rootsByTestFile[id] ?? [] }
4349
},
4450
getUnhandledErrors: async () => {
4551
return metadata.unhandledErrors

‎packages/ui/client/composables/module-graph.ts‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export type ModuleNode = GraphNode<ModuleType>
99
export type ModuleLink = GraphLink<ModuleType, ModuleNode>
1010
export type ModuleGraph = Graph<ModuleType, ModuleNode, ModuleLink>
1111
export type ModuleGraphController = GraphController<ModuleType, ModuleNode, ModuleLink>
12+
13+
const NODE_MODULES_RE = /[/\\]node_modules[/\\]/
14+
1215
function defineExternalModuleNodes(modules: string[]): ModuleNode[] {
1316
const labels = modules.map((module) => createModuleLabelItem(module))
1417
const map = calcExternalLabels(labels)
@@ -41,22 +44,47 @@ function defineInlineModuleNode(module: string, isRoot: boolean): ModuleNode {
4144
})
4245
}
4346

44-
export function getModuleGraph(data: ModuleGraphData, rootPath: string | undefined): ModuleGraph {
47+
export function getModuleGraph(
48+
data: ModuleGraphData | undefined,
49+
rootPath: string | undefined,
50+
hideNodeModules = false,
51+
): ModuleGraph {
4552
if (!data) {
4653
return defineGraph({})
4754
}
4855

56+
const inlined: string[] = []
57+
const externalized: string[] = []
58+
const seen = new Set<string>()
59+
function visit(id: string) {
60+
const module = data!.modules[id]
61+
if (seen.has(id) || !module) {
62+
return
63+
}
64+
seen.add(id)
65+
if (module.external) {
66+
externalized.push(id)
67+
return
68+
}
69+
inlined.push(id)
70+
module.imports.forEach(visit)
71+
}
72+
data.roots.forEach(visit)
73+
74+
const isVisible = (id: string) => !hideNodeModules || !NODE_MODULES_RE.test(id)
75+
const visibleInlined = inlined.filter(isVisible)
76+
const visibleExternalized = externalized.filter(isVisible)
4977
const externalizedNodes = !config.value.experimental?.viteModuleRunner
50-
? defineExternalModuleNodes([...data.inlined, ...data.externalized])
51-
: defineExternalModuleNodes(data.externalized)
78+
? defineExternalModuleNodes([...visibleInlined, ...visibleExternalized])
79+
: defineExternalModuleNodes(visibleExternalized)
5280
const inlinedNodes = !config.value.experimental?.viteModuleRunner
5381
? []
54-
: (data.inlined.map((module) => defineInlineModuleNode(module, module === rootPath)) ?? [])
82+
: visibleInlined.map((module) => defineInlineModuleNode(module, module === rootPath))
5583
const nodes = [...externalizedNodes, ...inlinedNodes]
5684
const nodeMap = Object.fromEntries(nodes.map((node) => [node.id, node]))
57-
const links = Object.entries(data.graph).flatMap(
58-
([module, deps]) =>
59-
deps
85+
const links = inlined.flatMap(
86+
(module) =>
87+
data.modules[module].imports
6088
.map((dep) => {
6189
const source = nodeMap[module]
6290
const target = nodeMap[dep]

‎packages/ui/node/reporter.ts‎

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { gzip, constants as zlibConstants } from 'node:zlib'
1414
import { stringify } from 'flatted'
1515
import { dirname, relative, resolve } from 'pathe'
1616
import c from 'tinyrainbow'
17-
import { getModuleGraph } from '../../vitest/src/utils/graph'
17+
import { getSharedModuleGraphByProject } from '../../vitest/src/utils/graph'
1818
import { distClientRoot } from './paths'
1919

2020
const gzipAsync = promisify(gzip)
@@ -120,7 +120,7 @@ async function serializeReportMetadata(
120120
files: [],
121121
config: ctx.serializedRootConfig,
122122
unhandledErrors: [...unhandledErrors],
123-
moduleGraph: {},
123+
moduleGraph: getSharedModuleGraphByProject(testModules),
124124
testModules: [],
125125
sourceCode: {
126126
codeTable: [],
@@ -147,8 +147,6 @@ async function serializeReportMetadata(
147147
return index
148148
}
149149

150-
const promises: Promise<void>[] = []
151-
152150
for (const testModule of testModules) {
153151
result.files.push(testModule.task)
154152

@@ -167,23 +165,8 @@ async function serializeReportMetadata(
167165
testModuleCodes[projectName][testModule.relativeModuleId] = getCodeIndex(code)
168166
} catch {}
169167
}
170-
171-
// TODO: https://github.com/vitest-dev/vitest/issues/9763
172-
promises.push(
173-
(async () => {
174-
result.moduleGraph[projectName] ??= {}
175-
result.moduleGraph[projectName][testModule.moduleId] = await getModuleGraph(
176-
ctx,
177-
projectName,
178-
testModule.moduleId,
179-
testModule.viteEnvironment?.name,
180-
)
181-
})(),
182-
)
183168
}
184169

185-
await Promise.all(promises)
186-
187170
return result
188171
}
189172

‎packages/vitest/src/types/general.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,22 @@ export interface UserConsoleLog {
2121
size: number
2222
}
2323

24+
interface ModuleGraphModules {
25+
[id: string]: { external: boolean; imports: string[] }
26+
}
27+
2428
export interface ModuleGraphData {
25-
graph: Record<string, string[]>
26-
externalized: string[]
27-
inlined: string[]
29+
modules: ModuleGraphModules
30+
roots: string[]
31+
}
32+
33+
export interface SharedModuleGraphByProject {
34+
[projectName: string]: { [environmentName: string]: SharedModuleGraphData }
35+
}
36+
37+
export interface SharedModuleGraphData {
38+
modules: ModuleGraphModules
39+
rootsByTestFile: { [testFile: string]: string[] }
2840
}
2941

3042
export interface ProvidedContext {}

‎packages/vitest/src/utils/graph.ts‎

Lines changed: 94 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { DevEnvironment, EnvironmentModuleNode } from 'vite'
22
import type { Vitest } from '../node/core'
3-
import type { ModuleGraphData } from '../types/general'
3+
import type { TestProject } from '../node/project'
4+
import type { TestModule } from '../node/reporters/reported-tasks'
5+
import type {
6+
ModuleGraphData,
7+
SharedModuleGraphByProject,
8+
SharedModuleGraphData,
9+
} from '../types/general'
410
import { getTestFileEnvironment } from './environments'
511

612
export async function getModuleGraph(
@@ -9,13 +15,57 @@ export async function getModuleGraph(
915
testFilePath: string,
1016
viteEnvironment?: string,
1117
): Promise<ModuleGraphData> {
12-
const graph: Record<string, string[]> = {}
13-
const externalized = new Set<string>()
14-
const inlined = new Set<string>()
15-
1618
const project = ctx.getProjectByName(projectName)
17-
const browser = project.config.browser.enabled
19+
const environment = getModuleGraphEnvironment(project, testFilePath, viteEnvironment)
20+
const collector = createModuleGraphCollector(project, environment)
21+
const roots = collector.add(testFilePath)
22+
return { modules: collector.data.modules, roots }
23+
}
1824

25+
export function getSharedModuleGraphByProject(
26+
testModules: ReadonlyArray<TestModule>,
27+
): SharedModuleGraphByProject {
28+
const testModulesByProject = new Map<TestProject, TestModule[]>()
29+
for (const testModule of testModules) {
30+
const projectTestModules = testModulesByProject.get(testModule.project) ?? []
31+
projectTestModules.push(testModule)
32+
testModulesByProject.set(testModule.project, projectTestModules)
33+
}
34+
35+
const result: SharedModuleGraphByProject = {}
36+
for (const [project, projectTestModules] of testModulesByProject) {
37+
result[project.name] = getSharedModuleGraphByEnvironment(project, projectTestModules)
38+
}
39+
return result
40+
}
41+
42+
function getSharedModuleGraphByEnvironment(
43+
project: TestProject,
44+
testModules: TestModule[],
45+
): SharedModuleGraphByProject[string] {
46+
const collectors: { [environmentName: string]: ModuleGraphCollector } = {}
47+
for (const testModule of testModules) {
48+
const environment = getModuleGraphEnvironment(
49+
project,
50+
testModule.moduleId,
51+
testModule.viteEnvironment?.name,
52+
)
53+
collectors[environment.name] ??= createModuleGraphCollector(project, environment)
54+
collectors[environment.name].add(testModule.moduleId)
55+
}
56+
return Object.fromEntries(
57+
Object.entries(collectors).map(([environmentName, collector]) => [
58+
environmentName,
59+
collector.data,
60+
]),
61+
)
62+
}
63+
64+
function getModuleGraphEnvironment(
65+
project: TestProject,
66+
testFilePath: string,
67+
viteEnvironment?: string,
68+
): DevEnvironment {
1969
let environment: DevEnvironment | undefined
2070

2171
if (viteEnvironment) {
@@ -24,15 +74,34 @@ export async function getModuleGraph(
2474
environment =
2575
project.config.experimental.viteModuleRunner === false
2676
? project.vite.environments.__vitest__
27-
: getTestFileEnvironment(project, testFilePath, browser)
77+
: getTestFileEnvironment(project, testFilePath, project.config.browser.enabled)
2878
}
2979

3080
if (!environment) {
3181
throw new Error(`Cannot find environment for ${testFilePath}`)
3282
}
83+
return environment
84+
}
85+
86+
interface ModuleGraphCollector {
87+
data: SharedModuleGraphData
88+
add: (testFilePath: string) => string[]
89+
}
90+
91+
function createModuleGraphCollector(
92+
project: TestProject,
93+
environment: DevEnvironment,
94+
): ModuleGraphCollector {
95+
const data: SharedModuleGraphData = { modules: {}, rootsByTestFile: {} }
96+
const browser = project.config.browser.enabled
3397
const seen = new Map<EnvironmentModuleNode, string>()
3498

35-
function get(mod?: EnvironmentModuleNode) {
99+
function addExternal(id: string) {
100+
data.modules[id] ??= { external: true, imports: [] }
101+
return id
102+
}
103+
104+
function get(mod?: EnvironmentModuleNode): string | undefined {
36105
if (!mod || !mod.id) {
37106
return
38107
}
@@ -50,37 +119,33 @@ export async function getModuleGraph(
50119
const id = clearId(mod.id)
51120
seen.set(mod, id)
52121
if (id.startsWith('__vite-browser-external:')) {
53-
const external = id.slice('__vite-browser-external:'.length)
54-
externalized.add(external)
55-
return external
122+
return addExternal(id.slice('__vite-browser-external:'.length))
56123
}
57124
const external = project._resolver.wasExternalized(id)
58125
if (typeof external === 'string') {
59-
externalized.add(external)
60-
return external
126+
return addExternal(external)
61127
}
62128
if (browser && mod.file?.includes(project.browser!.vite.config.cacheDir)) {
63-
externalized.add(mod.id)
64-
return id
129+
return addExternal(id)
65130
}
66-
inlined.add(id)
67-
const mods = Array.from(mod.importedModules).filter(
68-
(i) => i.id && !i.id.includes('/vitest/dist/'),
69-
)
70-
graph[id] = mods.map((m) => get(m)).filter(Boolean) as string[]
131+
data.modules[id] = { external: false, imports: [] }
132+
data.modules[id].imports = Array.from(mod.importedModules)
133+
.filter((i) => i.id && !i.id.includes('/vitest/dist/'))
134+
.map((m) => get(m))
135+
.filter((id) => id != null)
71136
return id
72137
}
73138

74-
get(environment.moduleGraph.getModuleById(testFilePath))
75-
project.config.setupFiles.forEach((setupFile) => {
76-
get(environment.moduleGraph.getModuleById(setupFile))
77-
})
78-
79-
return {
80-
graph,
81-
externalized: Array.from(externalized),
82-
inlined: Array.from(inlined),
139+
// graph roots of the test file including setup files
140+
function add(testFilePath: string): string[] {
141+
const roots = [testFilePath, ...project.config.setupFiles]
142+
.map((file) => get(environment.moduleGraph.getModuleById(file)))
143+
.filter((id) => id != null)
144+
data.rootsByTestFile[testFilePath] = roots
145+
return roots
83146
}
147+
148+
return { data, add }
84149
}
85150

86151
function clearId(id?: string | null) {

0 commit comments

Comments
 (0)