Skip to content
Open
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
9 changes: 6 additions & 3 deletions packages/cli/src/commands/dev/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export const dev: DevCommand = async ({
// all watchers
const watchers: FSWatcher[] = []

const { watchers: pageWatchers, cleanup: pageCleanup } = watchPageFiles(app)
// watch page files
watchers.push(...pageWatchers)

// restart dev command
const restart = async (): Promise<void> => {
await Promise.all([
Expand All @@ -74,6 +78,8 @@ export const dev: DevCommand = async ({
// close current dev server
close(),
])
// flush pending page file operations and cleanup
await pageCleanup()
// restart dev command
await dev({
defaultAppConfig,
Expand All @@ -88,9 +94,6 @@ export const dev: DevCommand = async ({
logger.tip(`dev server has restarted, please refresh your browser`)
}

// watch page files
watchers.push(...watchPageFiles(app))

// watch user config file
if (userConfigPath) {
watchers.push(
Expand Down
152 changes: 129 additions & 23 deletions packages/cli/src/commands/dev/watchPageFiles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import type { App, Page } from '@vuepress/core'
import { colors, logger, path, picomatch } from '@vuepress/utils'
import type { FSWatcher } from 'chokidar'
Expand All @@ -9,10 +8,43 @@ import { handlePageChange } from './handlePageChange.js'
import { handlePageUnlink } from './handlePageUnlink.js'
import { createPageDepsHelper } from './pageDepsHelper.js'

type PageEventType = 'add' | 'change' | 'unlink'

/**
* Merge pending events into final operation.
*/
const mergeEvents = (events: PageEventType[]): PageEventType | null => {
if (events.length === 0) return null

if (events.length === 1) return events[0]

const first = events[0]
const last = events[events.length - 1]

// add + ... + remove: nothing
if (first === 'add' && last === 'unlink') return null

if (first === 'add') return 'add'
if (last === 'unlink') return 'unlink'

return 'change'
}
Comment on lines +11 to +31
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new event coalescing/serialization logic (mergeEvents, pendingEvents, pagePromises) is non-trivial and affects dev hot-update correctness, but there are no tests covering the merge behavior (e.g. add→change, unlink→add, add→unlink) or serialization guarantees. Since this package already uses vitest for dev helpers, consider adding unit tests for the merge/queue behavior (possibly by extracting the merge logic into a testable helper).

Copilot uses AI. Check for mistakes.

/**
* Watch page files and deps, return file watchers
* Watch page files and deps, return file watchers and cleanup function
*/
export const watchPageFiles = (app: App): FSWatcher[] => {
export const watchPageFiles = (
app: App,
): {
watchers: FSWatcher[]
cleanup: () => Promise<void>
} => {
// Track pending events per page - just event types, no I/O
const pendingEvents = new Map<string, PageEventType[]>()

// Track the last promise per page for serialization
const pagePromises = new Map<string, Promise<void>>()

// watch page deps
const depsWatcher = chokidar.watch([], {
ignoreInitial: true,
Expand All @@ -26,13 +58,84 @@ export const watchPageFiles = (app: App): FSWatcher[] => {
const depsToRemove = depsHelper.remove(page)
depsWatcher.unwatch(depsToRemove)
}
const depsListener = async (dep: string): Promise<void> => {

// Process pending events for a page, merging them into one final operation
const processPageEvents = async (filePathRelative: string): Promise<void> => {
// Get and clear pending events for this page
const events = pendingEvents.get(filePathRelative) ?? []
pendingEvents.delete(filePathRelative)

// Merge events into final operation
const finalEvent = mergeEvents(events)
if (!finalEvent) return

const filePath = app.dir.source(filePathRelative)

if (finalEvent === 'add') {
logger.info(`page ${colors.magenta(filePathRelative)} is created`)
const page = await handlePageAdd(app, filePath)
if (page === null) return
addDeps(page)
return
}

if (finalEvent === 'change') {
logger.info(`page ${colors.magenta(filePathRelative)} is modified`)
const result = await handlePageChange(app, filePath)
if (result === null) return
const [pageOld, pageNew] = result
removeDeps(pageOld)
addDeps(pageNew)
return
}

// finalEvent is 'unlink'
logger.info(`page ${colors.magenta(filePathRelative)} is removed`)
const page = await handlePageUnlink(app, filePath)
if (page === null) return
removeDeps(page)
}

// Handle file events - just track them, no processing yet
const pageEventHandler = (
filePathRelative: string,
eventType: PageEventType,
): void => {
// Add event to pending list
let events = pendingEvents.get(filePathRelative)
if (!events) pendingEvents.set(filePathRelative, (events = []))
events.push(eventType)

// Chain to existing promise to ensure serialization
const existingPromise =
pagePromises.get(filePathRelative) ?? Promise.resolve()
const newPromise = (async () => {
await existingPromise
try {
await processPageEvents(filePathRelative)
} catch (error) {
logger.error(
`Error while processing page events for ${colors.magenta(filePathRelative)}:`,
error,
)
}
})()
// Only delete if this promise is still the current one (compare by identity)
.finally(() => {
if (pagePromises.get(filePathRelative) === newPromise)
pagePromises.delete(filePathRelative)
})
pagePromises.set(filePathRelative, newPromise)
}

// When a dependency changes, find all pages that depend on it and trigger change event for them
const depsListener = (dep: string): void => {
const pagePaths = depsHelper.get(dep)
for (const filePathRelative of pagePaths) {
logger.info(
`dependency of page ${colors.magenta(filePathRelative)} is modified`,
)
await handlePageChange(app, app.dir.source(filePathRelative))
pageEventHandler(filePathRelative, 'change')
}
}
depsWatcher.on('add', depsListener)
Expand Down Expand Up @@ -77,26 +180,29 @@ export const watchPageFiles = (app: App): FSWatcher[] => {
},
ignoreInitial: true,
})
pagesWatcher.on('add', async (filePathRelative) => {
logger.info(`page ${colors.magenta(filePathRelative)} is created`)
const page = await handlePageAdd(app, app.dir.source(filePathRelative))
if (page === null) return
addDeps(page)

pagesWatcher.on('add', (filePathRelative) => {
pageEventHandler(filePathRelative, 'add')
})
pagesWatcher.on('change', async (filePathRelative) => {
logger.info(`page ${colors.magenta(filePathRelative)} is modified`)
const result = await handlePageChange(app, app.dir.source(filePathRelative))
if (result === null) return
const [pageOld, pageNew] = result
removeDeps(pageOld)
addDeps(pageNew)
pagesWatcher.on('change', (filePathRelative) => {
pageEventHandler(filePathRelative, 'change')
})
pagesWatcher.on('unlink', async (filePathRelative) => {
logger.info(`page ${colors.magenta(filePathRelative)} is removed`)
const page = await handlePageUnlink(app, app.dir.source(filePathRelative))
if (page === null) return
removeDeps(page)
pagesWatcher.on('unlink', (filePathRelative) => {
pageEventHandler(filePathRelative, 'unlink')
})

return [pagesWatcher, depsWatcher]
// cancel queued page events, wait for in-flight operations to finish, and reset
const cleanup = async (): Promise<void> => {
// clear pending events
pendingEvents.clear()
// wait for all pending page operations to finish
await Promise.all(pagePromises.values())
// clear pending promises
pagePromises.clear()
Comment on lines +194 to +201
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup() clears pendingEvents, awaits current pagePromises, then clears pagePromises, but it does not prevent new events from being queued while cleanup() is awaiting. If cleanup() is called before/without closing the watchers, events can be enqueued during cleanup and then be dropped when pagePromises.clear() runs. Consider guarding pageEventHandler during cleanup (e.g. an isCleaningUp flag) or making cleanup() responsible for stopping the watchers (or at least documenting that watchers must be closed before calling cleanup).

Copilot uses AI. Check for mistakes.
}

return {
watchers: [pagesWatcher, depsWatcher],
cleanup,
}
}
Loading