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
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export const dev: DevCommand = async ({
6666
// all watchers
6767
const watchers: FSWatcher[] = []
6868

69+
const { watchers: pageWatchers, cleanup: pageCleanup } = watchPageFiles(app)
70+
// watch page files
71+
watchers.push(...pageWatchers)
72+
6973
// restart dev command
7074
const restart = async (): Promise<void> => {
7175
await Promise.all([
@@ -74,7 +78,8 @@ export const dev: DevCommand = async ({
7478
// close current dev server
7579
close(),
7680
])
77-
81+
// flush pending page file operations and cleanup
82+
await pageCleanup()
7883
// clean up internal app state after all watchers and server are closed
7984
app.writeTemp.cleanup()
8085

@@ -92,9 +97,6 @@ export const dev: DevCommand = async ({
9297
logger.tip(`dev server has restarted, please refresh your browser`)
9398
}
9499

95-
// watch page files
96-
watchers.push(...watchPageFiles(app))
97-
98100
// watch user config file
99101
if (userConfigPath) {
100102
watchers.push(

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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { mergeEvents } from '../../../src/commands/dev/watchPageFiles.js'
4+
5+
describe('mergeEvents', () => {
6+
it('should return null for empty events', () => {
7+
expect(mergeEvents([])).toBe(null)
8+
})
9+
10+
describe('single event', () => {
11+
it('should return "add" for a single add event', () => {
12+
expect(mergeEvents(['add'])).toBe('add')
13+
})
14+
15+
it('should return "change" for a single change event', () => {
16+
expect(mergeEvents(['change'])).toBe('change')
17+
})
18+
19+
it('should return "unlink" for a single unlink event', () => {
20+
expect(mergeEvents(['unlink'])).toBe('unlink')
21+
})
22+
})
23+
24+
describe('add → ...', () => {
25+
it('should merge add + change into "add" (file added then modified before processing)', () => {
26+
expect(mergeEvents(['add', 'change'])).toBe('add')
27+
})
28+
29+
it('should merge add + change + change into "add"', () => {
30+
expect(mergeEvents(['add', 'change', 'change'])).toBe('add')
31+
})
32+
33+
it('should merge add + unlink into null (file added then removed, nothing happened)', () => {
34+
expect(mergeEvents(['add', 'unlink'])).toBe(null)
35+
})
36+
37+
it('should merge add + change + unlink into null', () => {
38+
expect(mergeEvents(['add', 'change', 'unlink'])).toBe(null)
39+
})
40+
})
41+
42+
describe('unlink → ...', () => {
43+
it('should merge unlink + add into "change" (atomic save: remove then re-add equals a change)', () => {
44+
expect(mergeEvents(['unlink', 'add'])).toBe('change')
45+
})
46+
47+
it('should merge unlink + add + change into "change"', () => {
48+
expect(mergeEvents(['unlink', 'add', 'change'])).toBe('change')
49+
})
50+
})
51+
52+
describe('change → ...', () => {
53+
it('should merge change + change into "change"', () => {
54+
expect(mergeEvents(['change', 'change'])).toBe('change')
55+
})
56+
57+
it('should merge change + unlink into "unlink"', () => {
58+
expect(mergeEvents(['change', 'unlink'])).toBe('unlink')
59+
})
60+
61+
it('should merge change + change + change into "change"', () => {
62+
expect(mergeEvents(['change', 'change', 'change'])).toBe('change')
63+
})
64+
})
65+
})

0 commit comments

Comments
 (0)