@@ -8,6 +8,14 @@ import path from 'path'
8
8
9
9
import { readFileWithRetries } from './utils.js'
10
10
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
+
11
19
/**
12
20
*
13
21
* @param {* } args
@@ -42,27 +50,93 @@ export function createWatcher(args, { state, rebuild }) {
42
50
: false ,
43
51
} )
44
52
53
+ // A queue of rebuilds, file reads, etc… to run
45
54
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
+ */
47
61
let changedContent = [ ]
48
62
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
+
49
96
/**
50
97
*
51
98
* @param {* } file
52
99
* @param {(() => Promise<string>) | null } content
100
+ * @returns {Promise<void> }
53
101
*/
54
102
function recordChangedFile ( file , content = null ) {
55
103
file = path . resolve ( file )
56
104
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 )
58
114
59
115
changedContent . push ( {
60
116
file,
61
- content,
117
+ content : content ?? ( ( ) => fs . promises . readFile ( file , 'utf8' ) ) ,
62
118
extension : path . extname ( file ) . slice ( 1 ) ,
63
119
} )
64
120
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 )
66
140
67
141
return chain
68
142
}
@@ -107,18 +181,34 @@ export function createWatcher(args, { state, rebuild }) {
107
181
return
108
182
}
109
183
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
110
187
pendingRebuilds . add ( filePath )
111
188
112
- chain = chain . then ( async ( ) => {
113
- let content
114
-
189
+ async function enqueue ( ) {
115
190
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
119
206
}
207
+ }
120
208
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 )
122
212
} )
123
213
} )
124
214
0 commit comments