feat(cli): fix page file update racing problems and avoid middle stat… · vuepress/core@da12787 · GitHub
Skip to content

Commit da12787

Browse files
authored
feat(cli): fix page file update racing problems and avoid middle state processing (#1691)
1 parent 49abcb9 commit da12787

3 files changed

Lines changed: 201 additions & 27 deletions

File tree

packages/cli/src/commands/dev/dev.ts

Lines changed: 6 additions & 4 deletions

packages/cli/src/commands/dev/watchPageFiles.ts

Lines changed: 130 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-misused-promises */
21
import type { App, Page } from '@vuepress/core'
32
import { colors, logger, path, picomatch } from '@vuepress/utils'
43
import type { FSWatcher } from 'chokidar'
@@ -10,10 +9,44 @@ import { handlePageUnlink } from './handlePageUnlink.js'
109
import { createPageDepsHelper } from './pageDepsHelper.js'
1110
import { processPagePatterns } from './processPagePatterns.js'
1211

12+
type PageEventType = 'add' | 'change' | 'unlink'
13+
14+
/**
15+
* Merge pending events into final operation.
16+
* Exported for testing purposes.
17+
*/
18+
export const mergeEvents = (events: PageEventType[]): PageEventType | null => {
19+
if (events.length === 0) return null
20+
21+
if (events.length === 1) return events[0]
22+
23+
const first = events[0]
24+
const last = events[events.length - 1]
25+
26+
// add + ... + remove: nothing
27+
if (first === 'add' && last === 'unlink') return null
28+
29+
if (first === 'add') return 'add'
30+
if (last === 'unlink') return 'unlink'
31+
32+
return 'change'
33+
}
34+
1335
/**
14-
* Watch page files and deps, return file watchers
36+
* Watch page files and deps, return file watchers and cleanup function
1537
*/
16-
export const watchPageFiles = (app: App): FSWatcher[] => {
38+
export const watchPageFiles = (
39+
app: App,
40+
): {
41+
watchers: FSWatcher[]
42+
cleanup: () => Promise<void>
43+
} => {
44+
// Track pending events per page - just event types, no I/O
45+
const pendingEvents = new Map<string, PageEventType[]>()
46+
47+
// Track the last promise per page for serialization
48+
const pagePromises = new Map<string, Promise<void>>()
49+
1750
// watch page deps
1851
const depsWatcher = chokidar.watch([], {
1952
ignoreInitial: true,
@@ -27,13 +60,84 @@ export const watchPageFiles = (app: App): FSWatcher[] => {
2760
const depsToRemove = depsHelper.remove(page)
2861
depsWatcher.unwatch(depsToRemove)
2962
}
30-
const depsListener = async (dep: string): Promise<void> => {
63+
64+
// Process pending events for a page, merging them into one final operation
65+
const processPageEvents = async (filePathRelative: string): Promise<void> => {
66+
// Get and clear pending events for this page
67+
const events = pendingEvents.get(filePathRelative) ?? []
68+
pendingEvents.delete(filePathRelative)
69+
70+
// Merge events into final operation
71+
const finalEvent = mergeEvents(events)
72+
if (!finalEvent) return
73+
74+
const filePath = app.dir.source(filePathRelative)
75+
76+
if (finalEvent === 'add') {
77+
logger.info(`page ${colors.magenta(filePathRelative)} is created`)
78+
const page = await handlePageAdd(app, filePath)
79+
if (page === null) return
80+
addDeps(page)
81+
return
82+
}
83+
84+
if (finalEvent === 'change') {
85+
logger.info(`page ${colors.magenta(filePathRelative)} is modified`)
86+
const result = await handlePageChange(app, filePath)
87+
if (result === null) return
88+
const [pageOld, pageNew] = result
89+
removeDeps(pageOld)
90+
addDeps(pageNew)
91+
return
92+
}
93+
94+
// finalEvent is 'unlink'
95+
logger.info(`page ${colors.magenta(filePathRelative)} is removed`)
96+
const page = await handlePageUnlink(app, filePath)
97+
if (page === null) return
98+
removeDeps(page)
99+
}
100+
101+
// Handle file events - just track them, no processing yet
102+
const pageEventHandler = (
103+
filePathRelative: string,
104+
eventType: PageEventType,
105+
): void => {
106+
// Add event to pending list
107+
let events = pendingEvents.get(filePathRelative)
108+
if (!events) pendingEvents.set(filePathRelative, (events = []))
109+
events.push(eventType)
110+
111+
// Chain to existing promise to ensure serialization
112+
const existingPromise =
113+
pagePromises.get(filePathRelative) ?? Promise.resolve()
114+
const newPromise = (async () => {
115+
await existingPromise
116+
try {
117+
await processPageEvents(filePathRelative)
118+
} catch (error) {
119+
logger.error(
120+
`Error while processing page events for ${colors.magenta(filePathRelative)}:`,
121+
error,
122+
)
123+
}
124+
})()
125+
// Only delete if this promise is still the current one (compare by identity)
126+
.finally(() => {
127+
if (pagePromises.get(filePathRelative) === newPromise)
128+
pagePromises.delete(filePathRelative)
129+
})
130+
pagePromises.set(filePathRelative, newPromise)
131+
}
132+
133+
// When a dependency changes, find all pages that depend on it and trigger change event for them
134+
const depsListener = (dep: string): void => {
31135
const pagePaths = depsHelper.get(dep)
32136
for (const filePathRelative of pagePaths) {
33137
logger.info(
34138
`dependency of page ${colors.magenta(filePathRelative)} is modified`,
35139
)
36-
await handlePageChange(app, app.dir.source(filePathRelative))
140+
pageEventHandler(filePathRelative, 'change')
37141
}
38142
}
39143
depsWatcher.on('add', depsListener)
@@ -72,26 +176,29 @@ export const watchPageFiles = (app: App): FSWatcher[] => {
72176
},
73177
ignoreInitial: true,
74178
})
75-
pagesWatcher.on('add', async (filePathRelative) => {
76-
logger.info(`page ${colors.magenta(filePathRelative)} is created`)
77-
const page = await handlePageAdd(app, app.dir.source(filePathRelative))
78-
if (page === null) return
79-
addDeps(page)
179+
180+
pagesWatcher.on('add', (filePathRelative) => {
181+
pageEventHandler(filePathRelative, 'add')
80182
})
81-
pagesWatcher.on('change', async (filePathRelative) => {
82-
logger.info(`page ${colors.magenta(filePathRelative)} is modified`)
83-
const result = await handlePageChange(app, app.dir.source(filePathRelative))
84-
if (result === null) return
85-
const [pageOld, pageNew] = result
86-
removeDeps(pageOld)
87-
addDeps(pageNew)
183+
pagesWatcher.on('change', (filePathRelative) => {
184+
pageEventHandler(filePathRelative, 'change')
88185
})
89-
pagesWatcher.on('unlink', async (filePathRelative) => {
90-
logger.info(`page ${colors.magenta(filePathRelative)} is removed`)
91-
const page = await handlePageUnlink(app, app.dir.source(filePathRelative))
92-
if (page === null) return
93-
removeDeps(page)
186+
pagesWatcher.on('unlink', (filePathRelative) => {
187+
pageEventHandler(filePathRelative, 'unlink')
94188
})
95189

96-
return [pagesWatcher, depsWatcher]
190+
// cancel queued page events, wait for in-flight operations to finish, and reset
191+
const cleanup = async (): Promise<void> => {
192+
// clear pending events
193+
pendingEvents.clear()
194+
// wait for all pending page operations to finish
195+
await Promise.all(pagePromises.values())
196+
// clear pending promises
197+
pagePromises.clear()
198+
}
199+
200+
return {
201+
watchers: [pagesWatcher, depsWatcher],
202+
cleanup,
203+
}
97204
}
Lines changed: 65 additions & 0 deletions

0 commit comments

Comments
 (0)