@teambit/workspace 0.0.750 → 0.0.753

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/watch/watcher.ts CHANGED
@@ -42,8 +42,11 @@ export type WatchOptions = {
42
42
  preCompile?: boolean; // whether compile all components before start watching
43
43
  };
44
44
 
45
+ const DEBOUNCE_WAIT_MS = 100;
46
+
45
47
  export class Watcher {
46
48
  private fsWatcher: FSWatcher;
49
+ private changedFilesPerComponent: { [componentId: string]: string[] } = {};
47
50
  constructor(
48
51
  private workspace: Workspace,
49
52
  private pubsub: PubsubMain,
@@ -78,16 +81,22 @@ export class Watcher {
78
81
  // eslint-disable-next-line @typescript-eslint/no-misused-promises
79
82
  watcher.on('change', async (filePath) => {
80
83
  const startTime = new Date().getTime();
81
- const buildResults = (await this.handleChange(filePath, opts?.initiator)) || [];
84
+ const { files, results, debounced, failureMsg } = await this.handleChange(filePath, opts?.initiator);
85
+ if (debounced) {
86
+ return;
87
+ }
82
88
  const duration = new Date().getTime() - startTime;
83
- msgs?.onChange(filePath, buildResults, this.verbose, duration);
89
+ msgs?.onChange(files, results, this.verbose, duration, failureMsg);
84
90
  });
85
91
  // eslint-disable-next-line @typescript-eslint/no-misused-promises
86
92
  watcher.on('add', async (filePath) => {
87
93
  const startTime = new Date().getTime();
88
- const buildResults = (await this.handleChange(filePath)) || [];
94
+ const { files, results, debounced, failureMsg } = await this.handleChange(filePath, opts?.initiator);
95
+ if (debounced) {
96
+ return;
97
+ }
89
98
  const duration = new Date().getTime() - startTime;
90
- msgs?.onAdd(filePath, buildResults, this.verbose, duration);
99
+ msgs?.onAdd(files, results, this.verbose, duration, failureMsg);
91
100
  });
92
101
  // eslint-disable-next-line @typescript-eslint/no-misused-promises
93
102
  watcher.on('unlink', async (p) => {
@@ -101,28 +110,102 @@ export class Watcher {
101
110
  });
102
111
  }
103
112
 
104
- private async handleChange(filePath: string, initiator?: CompilationInitiator): Promise<OnComponentEventResult[]> {
113
+ /**
114
+ * some actions trigger multiple files changes at (almost) the same time. e.g. "git pull".
115
+ * this causes some performance and instability issues. a debouncing mechanism was implemented to help with this.
116
+ * the way how it works is that the first file of the same component starts the execution with a delay (e.g. 200ms).
117
+ * if, in the meanwhile, another file of the same component was changed, it won't start a new execution, instead,
118
+ * it'll only add the file to `this.changedFilesPerComponent` prop.
119
+ * once the execution starts, it'll delete this component-id from the `this.changedFilesPerComponent` array,
120
+ * indicating the next file-change to start a new execution.
121
+ *
122
+ * implementation wise, `lodash.debounce` doesn't help here, because:
123
+ * A) it doesn't return the results, unless "leading" option is true. here, it must be false, otherwise, it'll start
124
+ * the execution immediately.
125
+ * B) it debounces the method regardless the param passes to it. so it'll disregard the component-id and will delay
126
+ * other components undesirably.
127
+ *
128
+ */
129
+ private async handleChange(
130
+ filePath: string,
131
+ initiator?: CompilationInitiator
132
+ ): Promise<{
133
+ results: OnComponentEventResult[];
134
+ files?: string[];
135
+ failureMsg?: string;
136
+ debounced?: boolean;
137
+ }> {
105
138
  try {
106
139
  if (filePath.endsWith(BIT_MAP)) {
107
140
  const buildResults = await this.handleBitmapChanges();
108
- this.completeWatch();
109
- return buildResults;
141
+ loader.stop();
142
+ return { results: buildResults, files: [filePath] };
110
143
  }
111
- const componentId = await this.getComponentIdAndClearItsCache(filePath);
144
+ const componentId = this.getComponentIdByPath(filePath);
112
145
  if (!componentId) {
113
- logger.debug(`file ${filePath} is not part of any component, ignoring it`);
114
- return this.completeWatch();
146
+ const failureMsg = `file ${filePath} is not part of any component, ignoring it`;
147
+ logger.debug(failureMsg);
148
+ loader.stop();
149
+ return { results: [], files: [filePath], failureMsg };
115
150
  }
116
151
 
117
- const buildResults = await this.executeWatchOperationsOnComponent(componentId, [filePath], true, initiator);
118
- this.completeWatch();
119
- return buildResults;
152
+ const compIdStr = componentId.toString();
153
+ if (this.changedFilesPerComponent[compIdStr]) {
154
+ this.changedFilesPerComponent[compIdStr].push(filePath);
155
+ loader.stop();
156
+ return { results: [], debounced: true };
157
+ }
158
+ this.changedFilesPerComponent[compIdStr] = [filePath];
159
+ await this.sleep(DEBOUNCE_WAIT_MS);
160
+ const files = this.changedFilesPerComponent[compIdStr];
161
+ delete this.changedFilesPerComponent[compIdStr];
162
+
163
+ const buildResults = await this.triggerCompChanges(componentId, files, initiator);
164
+ const failureMsg = buildResults.length
165
+ ? undefined
166
+ : `files ${files.join(', ')} are inside the component ${compIdStr} but configured to be ignored`;
167
+ loader.stop();
168
+ return { results: buildResults, files, failureMsg };
120
169
  } catch (err: any) {
121
170
  const msg = `watcher found an error while handling ${filePath}`;
122
171
  logger.error(msg, err);
123
172
  logger.console(`${msg}, ${err.message}`);
173
+ loader.stop();
174
+ return { results: [], files: [filePath], failureMsg: err.message };
175
+ }
176
+ }
177
+
178
+ private async sleep(ms: number) {
179
+ return new Promise((resolve) => setTimeout(resolve, ms));
180
+ }
181
+
182
+ /**
183
+ * if a file was added/remove, once the component is loaded, it changes .bitmap, and then the
184
+ * entire cache is invalidated and the consumer is reloaded.
185
+ * when a file just changed, no need to reload the consumer, it is enough to just delete the
186
+ * component from the cache (both, workspace and consumer)
187
+ */
188
+ private async triggerCompChanges(
189
+ componentId: ComponentID,
190
+ files: string[],
191
+ initiator?: CompilationInitiator
192
+ ): Promise<OnComponentEventResult[]> {
193
+ this.workspace.clearComponentCache(componentId);
194
+ const component = await this.workspace.get(componentId);
195
+ const componentMap: ComponentMap = component.state._consumer.componentMap;
196
+ const compFiles = files.filter((filePath) => {
197
+ const relativeFile = this.getRelativePathLinux(filePath);
198
+ const isCompFile = Boolean(componentMap.getFilesRelativeToConsumer().find((p) => p === relativeFile));
199
+ if (!isCompFile) {
200
+ logger.debug(`file ${filePath} is inside the component ${componentId.toString()} but configured to be ignored`);
201
+ }
202
+ return isCompFile;
203
+ });
204
+ if (!compFiles.length) {
124
205
  return [];
125
206
  }
207
+ const buildResults = await this.executeWatchOperationsOnComponent(componentId, compFiles, true, initiator);
208
+ return buildResults;
126
209
  }
127
210
 
128
211
  /**
@@ -188,11 +271,7 @@ export class Watcher {
188
271
  logger.console(`\n${msg}: ${err.message || err}`);
189
272
  return [];
190
273
  }
191
- if (buildResults && buildResults.length) {
192
- return buildResults;
193
- }
194
- logger.console(`${idStr} doesn't have a compiler, nothing to build`);
195
- return [];
274
+ return buildResults;
196
275
  }
197
276
 
198
277
  private creatOnComponentRemovedEvent(idStr) {
@@ -207,11 +286,6 @@ export class Watcher {
207
286
  return new OnComponentAddEvent(Date.now(), idStr, hook);
208
287
  }
209
288
 
210
- private completeWatch() {
211
- loader.stop();
212
- return [];
213
- }
214
-
215
289
  private isComponentWatchedExternally(componentId: ComponentID) {
216
290
  const watcherData = this.multipleWatchers.find((m) => m.componentIds.find((id) => id.isEqual(componentId._legacy)));
217
291
  if (watcherData) {
@@ -221,29 +295,19 @@ export class Watcher {
221
295
  return false;
222
296
  }
223
297
 
224
- /**
225
- * if a file was added/remove, once the component is loaded, it changes .bitmap, and then the
226
- * entire cache is invalidated and the consumer is reloaded.
227
- * when a file just changed, no need to reload the consumer, it is enough to just delete the
228
- * component from the cache (both, workspace and consumer)
229
- */
230
- private async getComponentIdAndClearItsCache(filePath: string): Promise<ComponentID | null> {
231
- const relativeFile = pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(filePath));
298
+ private getComponentIdByPath(filePath: string): ComponentID | null {
299
+ const relativeFile = this.getRelativePathLinux(filePath);
232
300
  const trackDir = this.findTrackDirByFilePathRecursively(relativeFile);
233
301
  if (!trackDir) {
234
302
  // the file is not part of any component. If it was a new component, or a new file of
235
303
  // existing component, then, handleBitmapChanges() should deal with it.
236
304
  return null;
237
305
  }
238
- const componentId = this.trackDirs[trackDir];
239
- this.workspace.clearComponentCache(componentId);
240
- const component = await this.workspace.get(componentId);
241
- const componentMap: ComponentMap = component.state._consumer.componentMap;
242
- if (componentMap.getFilesRelativeToConsumer().find((p) => p === relativeFile)) {
243
- return componentId;
244
- }
245
- // the file is inside the component dir but it's ignored. (e.g. it's in IGNORE_LIST)
246
- return null;
306
+ return this.trackDirs[trackDir];
307
+ }
308
+
309
+ private getRelativePathLinux(filePath: string) {
310
+ return pathNormalizeToLinux(this.consumer.getPathRelativeToConsumer(filePath));
247
311
  }
248
312
 
249
313
  private findTrackDirByFilePathRecursively(filePath: string): string | null {