Skip to content

Commit

Permalink
fix(vite): refactor "module cache" to "evaluated modules", pass down …
Browse files Browse the repository at this point in the history
…module to "runInlinedModule" (#18092)
  • Loading branch information
sheremet-va authored Sep 25, 2024
1 parent 5e56614 commit e83beff
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 313 deletions.
4 changes: 2 additions & 2 deletions docs/guide/api-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ export class ModuleRunner {
The module evaluator in `ModuleRunner` is responsible for executing the code. Vite exports `ESModulesEvaluator` out of the box, it uses `new AsyncFunction` to evaluate the code. You can provide your own implementation if your JavaScript runtime doesn't support unsafe evaluation.
Module runner exposes `import` method. When Vite server triggers `full-reload` HMR event, all affected modules will be re-executed. Be aware that Module Runner doesn't update `exports` object when this happens (it overrides it), you would need to run `import` or get the module from `moduleCache` again if you rely on having the latest `exports` object.
Module runner exposes `import` method. When Vite server triggers `full-reload` HMR event, all affected modules will be re-executed. Be aware that Module Runner doesn't update `exports` object when this happens (it overrides it), you would need to run `import` or get the module from `evaluatedModules` again if you rely on having the latest `exports` object.
**Example Usage:**
Expand Down Expand Up @@ -704,7 +704,7 @@ export interface ModuleRunnerOptions {
/**
* Custom module cache. If not provided, it creates a separate module cache for each module runner instance.
*/
moduleCache?: ModuleCacheMap
evaluatedModules?: EvaluatedModules
}
```
Expand Down
147 changes: 147 additions & 0 deletions packages/vite/src/module-runner/evaluatedModules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { cleanUrl, isWindows, slash, unwrapId } from '../shared/utils'
import { SOURCEMAPPING_URL } from '../shared/constants'
import { decodeBase64 } from './utils'
import { DecodedMap } from './sourcemap/decoder'
import type { ResolvedResult } from './types'

const MODULE_RUNNER_SOURCEMAPPING_REGEXP = new RegExp(
`//# ${SOURCEMAPPING_URL}=data:application/json;base64,(.+)`,
)

export class EvaluatedModuleNode {
public importers = new Set<string>()
public imports = new Set<string>()
public evaluated = false
public meta: ResolvedResult | undefined
public promise: Promise<any> | undefined
public exports: any | undefined
public file: string
public map: DecodedMap | undefined

constructor(
public id: string,
public url: string,
) {
this.file = cleanUrl(id)
}
}

export class EvaluatedModules {
public readonly idToModuleMap = new Map<string, EvaluatedModuleNode>()
public readonly fileToModulesMap = new Map<string, Set<EvaluatedModuleNode>>()
public readonly urlToIdModuleMap = new Map<string, EvaluatedModuleNode>()

/**
* Returns the module node by the resolved module ID. Usually, module ID is
* the file system path with query and/or hash. It can also be a virtual module.
*
* Module runner graph will have 1 to 1 mapping with the server module graph.
* @param id Resolved module ID
*/
public getModuleById(id: string): EvaluatedModuleNode | undefined {
return this.idToModuleMap.get(id)
}

/**
* Returns all modules related to the file system path. Different modules
* might have different query parameters or hash, so it's possible to have
* multiple modules for the same file.
* @param file The file system path of the module
*/
public getModulesByFile(file: string): Set<EvaluatedModuleNode> | undefined {
return this.fileToModulesMap.get(file)
}

/**
* Returns the module node by the URL that was used in the import statement.
* Unlike module graph on the server, the URL is not resolved and is used as is.
* @param url Server URL that was used in the import statement
*/
public getModuleByUrl(url: string): EvaluatedModuleNode | undefined {
return this.urlToIdModuleMap.get(unwrapId(url))
}

/**
* Ensure that module is in the graph. If the module is already in the graph,
* it will return the existing module node. Otherwise, it will create a new
* module node and add it to the graph.
* @param id Resolved module ID
* @param url URL that was used in the import statement
*/
public ensureModule(id: string, url: string): EvaluatedModuleNode {
id = normalizeModuleId(id)
if (this.idToModuleMap.has(id)) {
const moduleNode = this.idToModuleMap.get(id)!
this.urlToIdModuleMap.set(url, moduleNode)
return moduleNode
}
const moduleNode = new EvaluatedModuleNode(id, url)
this.idToModuleMap.set(id, moduleNode)
this.urlToIdModuleMap.set(url, moduleNode)

const fileModules = this.fileToModulesMap.get(moduleNode.file) || new Set()
fileModules.add(moduleNode)
this.fileToModulesMap.set(moduleNode.file, fileModules)
return moduleNode
}

public invalidateModule(node: EvaluatedModuleNode): void {
node.evaluated = false
node.meta = undefined
node.map = undefined
node.promise = undefined
node.exports = undefined
// remove imports in case they are changed,
// don't remove the importers because otherwise it will be empty after evaluation
// this can create a bug when file was removed but it still triggers full-reload
// we are fine with the bug for now because it's not a common case
node.imports.clear()
}

/**
* Extracts the inlined source map from the module code and returns the decoded
* source map. If the source map is not inlined, it will return null.
* @param id Resolved module ID
*/
getModuleSourceMapById(id: string): DecodedMap | null {
const mod = this.getModuleById(id)
if (!mod) return null
if (mod.map) return mod.map
if (!mod.meta || !('code' in mod.meta)) return null
const mapString = MODULE_RUNNER_SOURCEMAPPING_REGEXP.exec(
mod.meta.code,
)?.[1]
if (!mapString) return null
mod.map = new DecodedMap(JSON.parse(decodeBase64(mapString)), mod.file)
return mod.map
}

public clear(): void {
this.idToModuleMap.clear()
this.fileToModulesMap.clear()
this.urlToIdModuleMap.clear()
}
}

// unique id that is not available as "$bare_import" like "test"
const prefixedBuiltins = new Set(['node:test', 'node:sqlite'])

// transform file url to id
// virtual:custom -> virtual:custom
// \0custom -> \0custom
// /root/id -> /id
// /root/id.js -> /id.js
// C:/root/id.js -> /id.js
// C:\root\id.js -> /id.js
function normalizeModuleId(file: string): string {
if (prefixedBuiltins.has(file)) return file

// unix style, but Windows path still starts with the drive letter to check the root
const unixFile = slash(file)
.replace(/^\/@fs\//, isWindows ? '' : '/')
.replace(/^node:/, '')
.replace(/^\/+/, '/')

// if it's not in the root, keep it as a path, not a URL
return unixFile.replace(/^file:\//, '/')
}
33 changes: 17 additions & 16 deletions packages/vite/src/module-runner/hmrHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,21 @@ export async function handleHotPayload(
}
case 'full-reload': {
const { triggeredBy } = payload
const clearEntrypoints = triggeredBy
const clearEntrypointUrls = triggeredBy
? getModulesEntrypoints(
runner,
getModulesByFile(runner, slash(triggeredBy)),
)
: findAllEntrypoints(runner)

if (!clearEntrypoints.size) break
if (!clearEntrypointUrls.size) break

hmrClient.logger.debug(`program reload`)
await hmrClient.notifyListeners('vite:beforeFullReload', payload)
runner.moduleCache.clear()
runner.evaluatedModules.clear()

for (const id of clearEntrypoints) {
await runner.import(id)
for (const url of clearEntrypointUrls) {
await runner.import(url)
}
break
}
Expand Down Expand Up @@ -120,14 +120,12 @@ class Queue {
}
}

function getModulesByFile(runner: ModuleRunner, file: string) {
const modules: string[] = []
for (const [id, mod] of runner.moduleCache.entries()) {
if (mod.meta && 'file' in mod.meta && mod.meta.file === file) {
modules.push(id)
}
function getModulesByFile(runner: ModuleRunner, file: string): string[] {
const nodes = runner.evaluatedModules.getModulesByFile(file)
if (!nodes) {
return []
}
return modules
return [...nodes].map((node) => node.id)
}

function getModulesEntrypoints(
Expand All @@ -139,9 +137,12 @@ function getModulesEntrypoints(
for (const moduleId of modules) {
if (visited.has(moduleId)) continue
visited.add(moduleId)
const module = runner.moduleCache.getByModuleId(moduleId)
const module = runner.evaluatedModules.getModuleById(moduleId)
if (!module) {
continue
}
if (module.importers && !module.importers.size) {
entrypoints.add(moduleId)
entrypoints.add(module.url)
continue
}
for (const importer of module.importers || []) {
Expand All @@ -155,9 +156,9 @@ function findAllEntrypoints(
runner: ModuleRunner,
entrypoints = new Set<string>(),
): Set<string> {
for (const [id, mod] of runner.moduleCache.entries()) {
for (const mod of runner.evaluatedModules.idToModuleMap.values()) {
if (mod.importers && !mod.importers.size) {
entrypoints.add(id)
entrypoints.add(mod.url)
}
}
return entrypoints
Expand Down
3 changes: 1 addition & 2 deletions packages/vite/src/module-runner/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// this file should re-export only things that don't rely on Node.js or other runner features

export { ModuleCacheMap } from './moduleCache'
export { EvaluatedModules, type EvaluatedModuleNode } from './evaluatedModules'
export { ModuleRunner } from './runner'
export { ESModulesEvaluator } from './esmEvaluator'
export { RemoteRunnerTransport } from './runnerTransport'
Expand All @@ -10,7 +10,6 @@ export type { HMRLogger, HMRConnection } from '../shared/hmr'
export type {
ModuleEvaluator,
ModuleRunnerContext,
ModuleCache,
FetchResult,
FetchFunction,
FetchFunctionOptions,
Expand Down
Loading

0 comments on commit e83beff

Please sign in to comment.