Skip to content

Commit 0a4ae77

Browse files
Fix not rebuilding files when rename event is emit (#9689)
* Fix CLI not rebuilding files when `rename` event is emit * Refactor watching code * Simplify * Add rebuild timer * Move timer into `recordChangedFile` Co-authored-by: Jordan Pittman <[email protected]>
1 parent d33b650 commit 0a4ae77

File tree

1 file changed

+101
-11
lines changed

1 file changed

+101
-11
lines changed

src/cli/build/watching.js

+101-11
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ import path from 'path'
88

99
import { readFileWithRetries } from './utils.js'
1010

11+
/**
12+
* The core idea of this watcher is:
13+
* 1. Whenever a file is added, changed, or renamed we queue a rebuild
14+
* 2. Perform as few rebuilds as possible by batching them together
15+
* 3. Coalesce events that happen in quick succession to avoid unnecessary rebuilds
16+
* 4. Ensure another rebuild happens _if_ changed while a rebuild is in progress
17+
*/
18+
1119
/**
1220
*
1321
* @param {*} args
@@ -42,27 +50,93 @@ export function createWatcher(args, { state, rebuild }) {
4250
: false,
4351
})
4452

53+
// A queue of rebuilds, file reads, etc… to run
4554
let chain = Promise.resolve()
46-
let pendingRebuilds = new Set()
55+
56+
/**
57+
* A list of files that have been changed since the last rebuild
58+
*
59+
* @type {{file: string, content: () => Promise<string>, extension: string}[]}
60+
*/
4761
let changedContent = []
4862

63+
/**
64+
* A list of files for which a rebuild has already been queued.
65+
* This is used to prevent duplicate rebuilds when multiple events are fired for the same file.
66+
* The rebuilt file is cleared from this list when it's associated rebuild has _started_
67+
* This is because if the file is changed during a rebuild it won't trigger a new rebuild which it should
68+
**/
69+
let pendingRebuilds = new Set()
70+
71+
let _timer
72+
let _reject
73+
74+
/**
75+
* Rebuilds the changed files and resolves when the rebuild is
76+
* complete regardless of whether it was successful or not
77+
*/
78+
async function rebuildAndContinue() {
79+
let changes = changedContent.splice(0)
80+
81+
// There are no changes to rebuild so we can just do nothing
82+
if (changes.length === 0) {
83+
return Promise.resolve()
84+
}
85+
86+
// Clear all pending rebuilds for the about-to-be-built files
87+
changes.forEach((change) => pendingRebuilds.delete(change.file))
88+
89+
// Resolve the promise even when the rebuild fails
90+
return rebuild(changes).then(
91+
() => {},
92+
() => {}
93+
)
94+
}
95+
4996
/**
5097
*
5198
* @param {*} file
5299
* @param {(() => Promise<string>) | null} content
100+
* @returns {Promise<void>}
53101
*/
54102
function recordChangedFile(file, content = null) {
55103
file = path.resolve(file)
56104

57-
content = content ?? (async () => await fs.promises.readFile(file, 'utf8'))
105+
// Applications like Vim/Neovim fire both rename and change events in succession for atomic writes
106+
// In that case rebuild has already been queued by rename, so can be skipped in change
107+
if (pendingRebuilds.has(file)) {
108+
return Promise.resolve()
109+
}
110+
111+
// Mark that a rebuild of this file is going to happen
112+
// It MUST happen synchronously before the rebuild is queued for this to be effective
113+
pendingRebuilds.add(file)
58114

59115
changedContent.push({
60116
file,
61-
content,
117+
content: content ?? (() => fs.promises.readFile(file, 'utf8')),
62118
extension: path.extname(file).slice(1),
63119
})
64120

65-
chain = chain.then(() => rebuild(changedContent.splice(0)))
121+
if (_timer) {
122+
clearTimeout(_timer)
123+
_reject()
124+
}
125+
126+
// If a rebuild is already in progress we don't want to start another one until the 10ms timer has expired
127+
chain = chain.then(
128+
() =>
129+
new Promise((resolve, reject) => {
130+
_timer = setTimeout(resolve, 10)
131+
_reject = reject
132+
})
133+
)
134+
135+
// Resolves once this file has been rebuilt (or the rebuild for this file has failed)
136+
// This queues as many rebuilds as there are changed files
137+
// But those rebuilds happen after some delay
138+
// And will immediately resolve if there are no changes
139+
chain = chain.then(rebuildAndContinue, rebuildAndContinue)
66140

67141
return chain
68142
}
@@ -107,18 +181,34 @@ export function createWatcher(args, { state, rebuild }) {
107181
return
108182
}
109183

184+
// We'll go ahead and add the file to the pending rebuilds list here
185+
// It'll be removed when the rebuild starts unless the read fails
186+
// which will be taken care of as well
110187
pendingRebuilds.add(filePath)
111188

112-
chain = chain.then(async () => {
113-
let content
114-
189+
async function enqueue() {
115190
try {
116-
content = await readFileWithRetries(path.resolve(filePath))
117-
} finally {
118-
pendingRebuilds.delete(filePath)
191+
// We need to read the file as early as possible outside of the chain
192+
// because it may be gone by the time we get to it. doing the read
193+
// immediately increases the chance that the file is still there
194+
let content = await readFileWithRetries(path.resolve(filePath))
195+
196+
if (content === undefined) {
197+
return
198+
}
199+
200+
// This will push the rebuild onto the chain
201+
// @ts-ignore: TypeScript isn't picking up that content is a string here
202+
await recordChangedFile(filePath, () => content)
203+
} catch {
204+
// If reading the file fails, it's was probably a deleted temporary file
205+
// So we can ignore it and no rebuild is needed
119206
}
207+
}
120208

121-
return recordChangedFile(filePath, () => content)
209+
enqueue().then(() => {
210+
// If the file read fails we still need to make sure the file isn't stuck in the pending rebuilds list
211+
pendingRebuilds.delete(filePath)
122212
})
123213
})
124214

0 commit comments

Comments
 (0)