@@ -47,37 +47,45 @@ export const createWatchBuild = async (
4747 const filesUpdated = new Set < string > ( ) ;
4848 const filesDeleted = new Set < string > ( ) ;
4949
50- // Track TypeScript files that need cache invalidation
50+ // TS files that need cache invalidation before the next rebuild
5151 const tsFilesToInvalidate = new Set < string > ( ) ;
5252
53- // Debounce rebuild calls to handle duplicate events from multiple watchers
53+ // Debounce timer — multiple watchers can fire for the same change
5454 let rebuildTimeout : ReturnType < typeof setTimeout > | null = null ;
5555
56- /**
57- * Trigger a rebuild of the project.
58- * Invalidates changed TypeScript files in the compiler cache, then rebuilds.
59- */
56+ // Suppress FSEvents double-events (the same save can fire twice ~200-500ms apart),
57+ // outside the 10ms debounce window. Drop events for files built within the cooldown period.
58+ const recentlyBuiltFiles = new Set < string > ( ) ;
59+ let lastBuildFinishedAt = 0 ;
60+ const DUPLICATE_EVENT_COOLDOWN_MS = 1500 ;
61+
62+ // Files the active build was triggered by; mid-build duplicates for these are dropped.
63+ const currentlyBuildingFiles = new Set < string > ( ) ;
64+
65+ /** Trigger a rebuild, invalidating changed TS files first. */
6066 const triggerRebuild = async ( ) => {
6167 if ( isBuilding ) {
62- // If already building, schedule another rebuild after current one finishes
6368 rebuildTimeout = setTimeout ( triggerRebuild , 50 ) ;
6469 return ;
6570 }
6671
6772 isBuilding = true ;
6873 try {
69- // Invalidate TypeScript cache for changed files
7074 if ( tsFilesToInvalidate . size > 0 ) {
7175 incrementalCompiler . invalidateFiles ( Array . from ( tsFilesToInvalidate ) ) ;
7276 tsFilesToInvalidate . clear ( ) ;
7377 }
7478
75- // Rebuild TypeScript program (incremental - only changed files re-emit)
76- const tsBuilder = incrementalCompiler . rebuild ( ) ;
79+ // Snapshot pending files so mid-build duplicates can be suppressed in onFsChange.
80+ currentlyBuildingFiles . clear ( ) ;
81+ filesAdded . forEach ( ( f ) => currentlyBuildingFiles . add ( f ) ) ;
82+ filesUpdated . forEach ( ( f ) => currentlyBuildingFiles . add ( f ) ) ;
83+ filesDeleted . forEach ( ( f ) => currentlyBuildingFiles . add ( f ) ) ;
7784
78- // Run the Stencil build
85+ const tsBuilder = incrementalCompiler . rebuild ( ) ;
7986 await onBuild ( tsBuilder ) ;
8087 } finally {
88+ currentlyBuildingFiles . clear ( ) ;
8189 isBuilding = false ;
8290 }
8391 } ;
@@ -121,9 +129,7 @@ export const createWatchBuild = async (
121129 ) ;
122130 }
123131
124- // Make sure all files in the module map are still in the fs
125- // Otherwise, we can run into build errors because the compiler can think
126- // there are two component files with the same tag name
132+ // Remove stale module map entries to prevent duplicate-tag build errors
127133 Array . from ( compilerCtx . moduleMap . keys ( ) ) . forEach ( ( key ) => {
128134 if ( filesUpdated . has ( key ) || filesDeleted . has ( key ) ) {
129135 // Check if the file exists in the fs
@@ -134,7 +140,7 @@ export const createWatchBuild = async (
134140 }
135141 } ) ;
136142
137- // Make sure all added/updated files are watched
143+ // Ensure newly added/updated files are watched
138144 new Set ( [ ...filesUpdated , ...filesAdded ] ) . forEach ( ( filePath ) => {
139145 compilerCtx . addWatchFile ( filePath ) ;
140146 } ) ;
@@ -148,63 +154,40 @@ export const createWatchBuild = async (
148154 emitFsChange ( compilerCtx , buildCtx ) ;
149155
150156 buildCtx . start ( ) ;
151-
152- // Rebuild the project
153157 const result = await build ( config , compilerCtx , buildCtx , tsBuilder ) ;
154158
155159 if ( result && ! result . hasError ) {
156160 isRebuild = true ;
157161 }
162+
163+ // Record consumed files so late-arriving OS duplicates are suppressed.
164+ recentlyBuiltFiles . clear ( ) ;
165+ buildCtx . filesChanged . forEach ( ( f ) => recentlyBuiltFiles . add ( f ) ) ;
166+ lastBuildFinishedAt = Date . now ( ) ;
158167 } ;
159168
160169 /**
161- * Utility method for formatting a debug message that must either list a number of files, or the word 'none' if the
162- * provided list is empty
163- *
164- * @param files a list of files, the list may be empty
165- * @returns the provided list if it is not empty. otherwise, return the word 'none'
170+ * Returns files as a prefixed list, or 'none' if empty.
171+ * No space before the filename — the logger wraps on whitespace.
172+ * @param files the list of files to format for debug output
173+ * @returns the formatted string for debug output
166174 */
167175 const formatFilesForDebug = ( files : ReadonlyArray < string > ) : string => {
168- /**
169- * In the created message, it's important that there's no whitespace prior to the file name.
170- * Stencil's logger will split messages by whitespace according to the width of the terminal window.
171- * Since file names can be fully qualified paths (and therefore quite long), putting whitespace between a '-' and
172- * the path can lead to formatted messages where the '-' is on its own line
173- */
174176 return files . length > 0 ? files . map ( ( filename : string ) => `-${ filename } ` ) . join ( '\n' ) : 'none' ;
175177 } ;
176178
177179 /**
178- * Utility method to start/construct the watch program. This will mark
179- * all relevant files to be watched and then do the initial build.
180- *
181- * @returns A promise that resolves when the watcher is closed.
180+ * Start watchers for all relevant directories and run the initial build.
181+ * @returns a promise that resolves when the watch program is closed.
182182 */
183183 const start = async ( ) => {
184- /**
185- * Stencil watches the following directories for changes:
186- */
187184 await Promise . all ( [
188- /**
189- * the `srcDir` directory, e.g. component files
190- */
191185 watchFiles ( compilerCtx , config . srcDir ) ,
192- /**
193- * the root directory, e.g. `stencil.config.ts`
194- */
195- watchFiles ( compilerCtx , config . rootDir , {
196- recursive : false ,
197- } ) ,
198- /**
199- * the external directories, defined in `watchExternalDirs`, e.g. `node_modules`
200- */
186+ watchFiles ( compilerCtx , config . rootDir , { recursive : false } ) ,
201187 ...( config . watchExternalDirs || [ ] ) . map ( ( dir ) => watchFiles ( compilerCtx , dir ) ) ,
202188 ] ) ;
203189
204- // Create the incremental TypeScript compiler
205190 incrementalCompiler = new IncrementalCompiler ( config ) ;
206-
207- // Initial build
208191 const tsBuilder = incrementalCompiler . rebuild ( ) ;
209192 await onBuild ( tsBuilder ) ;
210193
@@ -232,6 +215,18 @@ export const createWatchBuild = async (
232215 */
233216 const onFsChange : d . CompilerFileWatcherCallback = ( filePath , eventKind ) => {
234217 if ( incrementalCompiler && ! isWatchIgnorePath ( config , filePath ) ) {
218+ // Drop duplicate OS events: same file within cooldown window, or mid-build duplicate.
219+ const isDuplicateOfRecentBuild =
220+ recentlyBuiltFiles . has ( filePath ) &&
221+ Date . now ( ) - lastBuildFinishedAt < DUPLICATE_EVENT_COOLDOWN_MS ;
222+ const isDuplicateMidBuild = isBuilding && currentlyBuildingFiles . has ( filePath ) ;
223+ if ( isDuplicateOfRecentBuild || isDuplicateMidBuild ) {
224+ config . logger . debug (
225+ `WATCH_BUILD::fs_event_change suppressed duplicate - type=${ eventKind } , path=${ filePath } ` ,
226+ ) ;
227+ return ;
228+ }
229+
235230 updateCompilerCtxCache ( config , compilerCtx , filePath , eventKind ) ;
236231
237232 switch ( eventKind ) {
@@ -252,7 +247,6 @@ export const createWatchBuild = async (
252247 break ;
253248 }
254249
255- // Track TypeScript files for cache invalidation
256250 if ( filePath . endsWith ( '.ts' ) || filePath . endsWith ( '.tsx' ) ) {
257251 tsFilesToInvalidate . add ( filePath ) ;
258252 }
@@ -261,7 +255,6 @@ export const createWatchBuild = async (
261255 `WATCH_BUILD::fs_event_change - type=${ eventKind } , path=${ filePath } , time=${ new Date ( ) . getTime ( ) } ` ,
262256 ) ;
263257
264- // Debounce rebuild calls - multiple watchers may fire for the same change
265258 if ( rebuildTimeout ) {
266259 clearTimeout ( rebuildTimeout ) ;
267260 }
0 commit comments