pull/4525/head
Divyansh Singh 7 months ago
parent c61800b341
commit 24727af4e8

@ -2,7 +2,7 @@ import paths from './paths'
export default { export default {
async paths(watchedFiles: string[]) { async paths(watchedFiles: string[]) {
console.log('watchedFiles', watchedFiles) // console.log('watchedFiles', watchedFiles)
return paths return paths
}, },
watch: ['**/data-loading/**/*.json'] watch: ['**/data-loading/**/*.json']

@ -0,0 +1,72 @@
import { ModuleGraph } from 'node/utils/moduleGraph'
describe('node/utils/moduleGraph', () => {
let graph: ModuleGraph
beforeEach(() => {
graph = new ModuleGraph()
})
it('should correctly delete a module and its dependents', () => {
graph.add('A', ['B', 'C'])
graph.add('B', ['D'])
graph.add('C', [])
graph.add('D', [])
expect(graph.delete('D')).toEqual(new Set(['D', 'B', 'A']))
})
it('should handle shared dependencies correctly', () => {
graph.add('A', ['B', 'C'])
graph.add('B', ['D'])
graph.add('C', ['D']) // Shared dependency
graph.add('D', [])
expect(graph.delete('D')).toEqual(new Set(['A', 'B', 'C', 'D']))
})
it('merges dependencies correctly', () => {
// Add module A with dependency B
graph.add('A', ['B'])
// Merge new dependency C into module A (B should remain)
graph.add('A', ['C'])
// Deleting B should remove A as well, since A depends on B.
expect(graph.delete('B')).toEqual(new Set(['B', 'A']))
})
it('handles cycles gracefully', () => {
// Create a cycle: A -> B, B -> C, C -> A.
graph.add('A', ['B'])
graph.add('B', ['C'])
graph.add('C', ['A'])
// Deleting any module in the cycle should delete all modules in the cycle.
expect(graph.delete('A')).toEqual(new Set(['A', 'B', 'C']))
})
it('cleans up dependencies when deletion', () => {
// Setup A -> B relationship.
graph.add('A', ['B'])
graph.add('B', [])
// Deleting B should remove both B and A from the graph.
expect(graph.delete('B')).toEqual(new Set(['B', 'A']))
// After deletion, add modules again.
graph.add('C', [])
graph.add('A', ['C']) // Now A depends only on C.
expect(graph.delete('C')).toEqual(new Set(['C', 'A']))
})
it('handles independent modules', () => {
// Modules with no dependencies.
graph.add('X', [])
graph.add('Y', [])
// Deletion of one should only remove that module.
expect(graph.delete('X')).toEqual(new Set(['X']))
expect(graph.delete('Y')).toEqual(new Set(['Y']))
})
})

@ -1,6 +1,7 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'node:path' import path from 'node:path'
import c from 'picocolors' import c from 'picocolors'
import { isMatch } from 'picomatch'
import { glob } from 'tinyglobby' import { glob } from 'tinyglobby'
import { import {
loadConfigFromFile, loadConfigFromFile,
@ -9,12 +10,48 @@ import {
type Logger, type Logger,
type Plugin type Plugin
} from 'vite' } from 'vite'
import type { Awaitable } from '../shared'
import { type SiteConfig, type UserConfig } from '../siteConfig' import { type SiteConfig, type UserConfig } from '../siteConfig'
import { ModuleGraph } from '../utils/moduleGraph'
import { resolveRewrites } from './rewritesPlugin' import { resolveRewrites } from './rewritesPlugin'
export const dynamicRouteRE = /\[(\w+?)\]/g interface UserRouteConfig {
params: Record<string, string>
content?: string
}
export type ResolvedRouteConfig = UserRouteConfig & {
/**
* the raw route (relative to src root), e.g. foo/[bar].md
*/
route: string
/**
* the actual path with params resolved (relative to src root), e.g. foo/1.md
*/
path: string
/**
* absolute fs path
*/
fullPath: string
}
export interface RouteModule {
watch?: string[] | string
paths:
| UserRouteConfig[]
| ((watchedFiles: string[]) => Awaitable<UserRouteConfig[]>)
}
interface ResolvedRouteModule {
watch: string[] | undefined
routes: ResolvedRouteConfig[] | undefined
loader: RouteModule['paths']
}
let fileToModulesMap: Record<string, Set<string>> = {} const dynamicRouteRE = /\[(\w+?)\]/g
const routeModuleCache = new Map<string, ResolvedRouteModule>()
let moduleGraph = new ModuleGraph()
export async function resolvePages( export async function resolvePages(
srcDir: string, srcDir: string,
@ -47,58 +84,25 @@ export async function resolvePages(
;(dynamicRouteRE.test(file) ? dynamicRouteFiles : pages).push(file) ;(dynamicRouteRE.test(file) ? dynamicRouteFiles : pages).push(file)
}) })
const { routes, fileToModulesMap: fileToModulesMap_ } = const dynamicRoutes = await resolveDynamicRoutes(
await resolveDynamicRoutes(srcDir, dynamicRouteFiles, logger) srcDir,
dynamicRouteFiles,
logger
)
pages.push(...routes.map((r) => r.path)) pages.push(...dynamicRoutes.map((r) => r.path))
fileToModulesMap = fileToModulesMap_
const rewrites = resolveRewrites(pages, userConfig.rewrites) const rewrites = resolveRewrites(pages, userConfig.rewrites)
return { return {
pages, pages,
dynamicRoutes: { routes }, dynamicRoutes,
rewrites, rewrites,
// @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts // @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts
__dirty: true __dirty: true
} }
} }
interface UserRouteConfig {
params: Record<string, string>
content?: string
}
interface RouteModule {
path: string
config: {
watch?: string[] | string
paths:
| UserRouteConfig[]
| ((
watchedFiles: string[]
) => UserRouteConfig[] | Promise<UserRouteConfig[]>)
}
dependencies: string[]
}
const routeModuleCache = new Map<string, RouteModule>()
export type ResolvedRouteConfig = UserRouteConfig & {
/**
* the raw route (relative to src root), e.g. foo/[bar].md
*/
route: string
/**
* the actual path with params resolved (relative to src root), e.g. foo/1.md
*/
path: string
/**
* absolute fs path
*/
fullPath: string
}
export const dynamicRoutesPlugin = async ( export const dynamicRoutesPlugin = async (
config: SiteConfig config: SiteConfig
): Promise<Plugin> => { ): Promise<Plugin> => {
@ -110,7 +114,7 @@ export const dynamicRoutesPlugin = async (
const normalizedId = id.startsWith(config.srcDir) const normalizedId = id.startsWith(config.srcDir)
? id ? id
: normalizePath(path.resolve(config.srcDir, id.replace(/^\//, ''))) : normalizePath(path.resolve(config.srcDir, id.replace(/^\//, '')))
const matched = config.dynamicRoutes.routes.find( const matched = config.dynamicRoutes.find(
(r) => r.fullPath === normalizedId (r) => r.fullPath === normalizedId
) )
if (matched) { if (matched) {
@ -119,11 +123,11 @@ export const dynamicRoutesPlugin = async (
}, },
load(id) { load(id) {
const matched = config.dynamicRoutes.routes.find((r) => r.fullPath === id) const matched = config.dynamicRoutes.find((r) => r.fullPath === id)
if (matched) { if (matched) {
const { route, params, content } = matched const { route, params, content } = matched
const routeFile = normalizePath(path.resolve(config.srcDir, route)) const routeFile = normalizePath(path.resolve(config.srcDir, route))
fileToModulesMap[routeFile].add(id) moduleGraph.add(id, [routeFile])
let baseContent = fs.readFileSync(routeFile, 'utf-8') let baseContent = fs.readFileSync(routeFile, 'utf-8')
@ -147,51 +151,49 @@ export const dynamicRoutesPlugin = async (
async hotUpdate({ file, modules: existingMods }) { async hotUpdate({ file, modules: existingMods }) {
if (this.environment.name !== 'client') return if (this.environment.name !== 'client') return
const modules = new Set<EnvironmentModuleNode>()
const normalizedFile = normalizePath(file) const normalizedFile = normalizePath(file)
// Invalidate any cached route modules whose key or dependencies include the changed file.
for (const [cacheKey, mod] of routeModuleCache.entries()) { // Trigger update if a module or its dependencies changed.
const normalizedCacheKey = normalizePath(cacheKey) for (const id of moduleGraph.delete(normalizedFile)) {
if ( routeModuleCache.delete(id)
normalizedCacheKey === normalizedFile || const mod = this.environment.moduleGraph.getModuleById(id)
mod.dependencies.some( if (mod) {
(dep) => normalizePath(path.resolve(dep)) === normalizedFile modules.add(mod)
)
) {
routeModuleCache.delete(cacheKey)
} }
} }
const modules: EnvironmentModuleNode[] = [] // Also check if the file matches any custom watch patterns.
let watchedFileChanged = false
for (const [, route] of routeModuleCache) {
if (route.watch && isMatch(normalizedFile, route.watch)) {
route.routes = undefined
watchedFileChanged = true
}
}
const mods = fileToModulesMap[normalizedFile] if (modules.size || watchedFileChanged) {
if (mods) {
// path loader module or deps updated, reset loaded routes // path loader module or deps updated, reset loaded routes
if (!normalizedFile.endsWith('.md')) { if (!normalizedFile.endsWith('.md') || watchedFileChanged) {
Object.assign( Object.assign(
config, config,
await resolvePages(config.srcDir, config.userConfig, config.logger) await resolvePages(config.srcDir, config.userConfig, config.logger)
) )
} }
for (const id of mods) {
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
}
}
} }
return modules.length > 0 ? [...existingMods, ...modules] : undefined return modules.size ? [...existingMods, ...modules] : undefined
} }
} }
} }
export async function resolveDynamicRoutes( async function resolveDynamicRoutes(
srcDir: string, srcDir: string,
routes: string[], routes: string[],
logger: Logger logger: Logger
) { ): Promise<ResolvedRouteConfig[]> {
const pendingResolveRoutes: Promise<ResolvedRouteConfig[]>[] = [] const pendingResolveRoutes: Promise<ResolvedRouteConfig[]>[] = []
const routeFileToModulesMap: Record<string, Set<string>> = {} const newModuleGraph = moduleGraph.clone()
for (const route of routes) { for (const route of routes) {
// locate corresponding route paths file // locate corresponding route paths file
@ -214,78 +216,80 @@ export async function resolveDynamicRoutes(
} }
// load the paths loader module // load the paths loader module
let mod = routeModuleCache.get(pathsFile) let watch: ResolvedRouteModule['watch']
if (!mod) { let loader: ResolvedRouteModule['loader']
const existing = routeModuleCache.get(normalizePath(pathsFile))
if (existing) {
// use cached routes if not invalidated by hmr
if (existing.routes) {
pendingResolveRoutes.push(Promise.resolve(existing.routes))
continue
}
;({ watch, loader } = existing)
} else {
let mod
try { try {
mod = (await loadConfigFromFile( mod = await loadConfigFromFile(
{} as any, {} as any,
pathsFile, pathsFile,
undefined, undefined,
'silent' 'silent'
)) as RouteModule )
routeModuleCache.set(pathsFile, mod)
} catch (err: any) { } catch (err: any) {
logger.warn( logger.warn(
`${c.yellow(`Failed to load ${pathsFile}:`)}\n${err.message}\n${err.stack}` `${c.yellow(`Failed to load ${pathsFile}:`)}\n${err.message}\n${err.stack}`
) )
continue continue
} }
}
const loader = mod.config.paths if (!mod) {
if (!loader) { logger.warn(
logger.warn( c.yellow(
c.yellow( `Invalid paths file export in ${pathsFile}. ` +
`Invalid paths file export in ${pathsFile}. ` + `Missing "default" export.`
`Missing "paths" property from default export.` )
) )
) continue
continue }
}
// Create or retrieve the set of virtual module IDs affected by this route. const routeModule = mod.config as RouteModule
const routeKey = normalizePath(path.resolve(srcDir, route))
const matchedModuleIds = loader = routeModule.paths
routeFileToModulesMap[routeKey] || new Set<string>() if (!loader) {
routeFileToModulesMap[routeKey] = matchedModuleIds logger.warn(
c.yellow(
// Track loader dependencies (merging sets if shared) `Invalid paths file export in ${pathsFile}. ` +
for (const dep of mod.dependencies) { `Missing "paths" property from default export.`
const depPath = normalizePath(path.resolve(dep)) )
if (!routeFileToModulesMap[depPath]) { )
routeFileToModulesMap[depPath] = matchedModuleIds continue
} else {
for (const id of matchedModuleIds) {
routeFileToModulesMap[depPath].add(id)
}
} }
}
// Process custom watch files if provided.
let watch: string[] | undefined
if (mod.config.watch) {
watch = watch =
typeof mod.config.watch === 'string' typeof routeModule.watch === 'string'
? [mod.config.watch] ? [routeModule.watch]
: mod.config.watch : routeModule.watch
watch = watch.map((p) => if (watch) {
p.startsWith('.') watch = watch.map((p) =>
? normalizePath(path.resolve(path.dirname(pathsFile), p)) p.startsWith('.')
: normalizePath(p) ? normalizePath(path.resolve(path.dirname(pathsFile), p))
) : normalizePath(p)
for (const watchFile of watch) { )
if (!routeFileToModulesMap[watchFile]) {
routeFileToModulesMap[watchFile] = matchedModuleIds
} else {
for (const id of matchedModuleIds) {
routeFileToModulesMap[watchFile].add(id)
}
}
} }
// record deps for hmr
newModuleGraph.add(
normalizePath(pathsFile),
mod.dependencies.map((f) => normalizePath(path.resolve(f)))
)
newModuleGraph.add(fullPath, [normalizePath(pathsFile)])
} }
const resolveRoute = async (): Promise<ResolvedRouteConfig[]> => { const resolveRoute = async (): Promise<ResolvedRouteConfig[]> => {
let pathsData: UserRouteConfig[] let pathsData: UserRouteConfig[]
if (typeof loader === 'function') { if (typeof loader === 'function') {
let watchedFiles: string[] = [] let watchedFiles: string[] = []
if (watch) { if (watch) {
@ -300,7 +304,8 @@ export async function resolveDynamicRoutes(
} else { } else {
pathsData = loader pathsData = loader
} }
return pathsData.map((userConfig) => {
const routes = pathsData.map((userConfig) => {
const resolvedPath = route.replace( const resolvedPath = route.replace(
dynamicRouteRE, dynamicRouteRE,
(_, key) => userConfig.params[key] (_, key) => userConfig.params[key]
@ -312,12 +317,17 @@ export async function resolveDynamicRoutes(
...userConfig ...userConfig
} }
}) })
routeModuleCache.set(normalizePath(pathsFile), { watch, routes, loader })
return routes
} }
pendingResolveRoutes.push(resolveRoute()) pendingResolveRoutes.push(resolveRoute())
} }
return { const resolvedRoutes = (await Promise.all(pendingResolveRoutes)).flat()
routes: (await Promise.all(pendingResolveRoutes)).flat(), moduleGraph = newModuleGraph
fileToModulesMap: routeFileToModulesMap
} return resolvedRoutes
} }

@ -0,0 +1,115 @@
export class ModuleGraph {
// Each module is tracked with its dependencies and dependents.
private nodes: Map<
string,
{ dependencies: Set<string>; dependents: Set<string> }
> = new Map()
/**
* Adds or updates a module by merging the provided dependencies
* with any existing ones.
*
* For every new dependency, the module is added to that dependency's
* 'dependents' set.
*
* @param module - The module to add or update.
* @param dependencies - Array of module names that the module depends on.
*/
add(module: string, dependencies: string[]): void {
// Ensure the module exists in the graph.
if (!this.nodes.has(module)) {
this.nodes.set(module, {
dependencies: new Set(),
dependents: new Set()
})
}
const moduleNode = this.nodes.get(module)!
// Merge the new dependencies with any that already exist.
for (const dep of dependencies) {
if (!moduleNode.dependencies.has(dep)) {
moduleNode.dependencies.add(dep)
// Ensure the dependency exists in the graph.
if (!this.nodes.has(dep)) {
this.nodes.set(dep, {
dependencies: new Set(),
dependents: new Set()
})
}
// Add the module as a dependent of the dependency.
this.nodes.get(dep)!.dependents.add(module)
}
}
}
/**
* Deletes a module and all modules that (transitively) depend on it.
*
* This method performs a depth-first search from the target module,
* collects all affected modules, and then removes them from the graph,
* cleaning up their references from other nodes.
*
* @param module - The module to delete.
* @returns A Set containing the deleted module and all modules that depend on it.
*/
delete(module: string): Set<string> {
const deleted = new Set<string>()
const stack: string[] = [module]
// Traverse the reverse dependency graph (using dependents).
while (stack.length) {
const current = stack.pop()!
if (!deleted.has(current)) {
deleted.add(current)
const node = this.nodes.get(current)
if (node) {
for (const dependent of node.dependents) {
stack.push(dependent)
}
}
}
}
// Remove deleted nodes from the graph.
// For each deleted node, also remove it from its dependencies' dependents.
for (const mod of deleted) {
const node = this.nodes.get(mod)
if (node) {
for (const dep of node.dependencies) {
const depNode = this.nodes.get(dep)
if (depNode) {
depNode.dependents.delete(mod)
}
}
}
this.nodes.delete(mod)
}
return deleted
}
/**
* Clears all modules from the graph.
*/
clear(): void {
this.nodes.clear()
}
/**
* Creates a deep clone of the ModuleGraph instance.
* This is useful for preserving the state of the graph
* before making modifications.
*
* @returns A new ModuleGraph instance with the same state as the original.
*/
clone(): ModuleGraph {
const clone = new ModuleGraph()
for (const [module, { dependencies, dependents }] of this.nodes) {
clone.nodes.set(module, {
dependencies: new Set(dependencies),
dependents: new Set(dependents)
})
}
return clone
}
}
Loading…
Cancel
Save