@@ -8,6 +8,14 @@ import path from 'path'
88
99import { 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