@ttsc/unplugin 0.30.3 → 0.30.4

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.
@@ -1,38 +1,180 @@
1
- import { watch } from 'chokidar';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
3
+ import { createFilesystemPathIdentityContext } from 'ttsc/path-identity';
4
4
  import { captureWatchInputBaseline, validateGraphInputObservation, watchInputEvidenceMatchesBaseline, pathIdentityKey } from './transform.mjs';
5
5
 
6
6
  /**
7
- * One filesystem subscription per unique input, shared by all served modules.
7
+ * A bounded recursive filesystem observer shared by all served modules.
8
8
  *
9
9
  * Vite resolves transform-context addWatchFile as a runtime import, including
10
10
  * type-only .server files and non-module plugin assets. Use a separate watcher
11
11
  * for compiler inputs, including node_modules, which Vite's watcher ignores.
12
12
  * Ordinary files use events after their initial subscription is observed.
13
- * Missing spellings and directory predicates keep a shared predicate poll.
14
- * Linked files also share topology checks by directory because retargeting a
15
- * junction need not emit events on its previously watched descendants.
13
+ * Missing spellings and directory predicates use the recursive observer for
14
+ * their nearest available scope. Inputs a native scope cannot safely cover
15
+ * share one bounded fallback poll; linked files also share topology checks
16
+ * because retargeting a junction need not emit events on its old descendants.
16
17
  */
17
- function createViteServeInputWatch() {
18
+ function createViteServeInputWatch(operations = {}) {
18
19
  const entries = new Map();
20
+ const aliases = new Map();
21
+ const renameAliases = new Map();
19
22
  const importerInputs = new Map();
20
23
  const pending = new Set();
24
+ const polled = new Set();
21
25
  const links = new Map();
26
+ const scopes = new Map();
27
+ const componentLinks = new Map();
28
+ const missingComponents = new Set();
29
+ const changes = new Map();
22
30
  let server;
23
- let watcher;
31
+ let projectRoot;
32
+ let changeSequence = 0;
33
+ let historyFloor = 0;
34
+ let linkIterator;
35
+ let pollIterator;
24
36
  let poller;
25
37
  let flushTimer;
26
- let failed = false;
38
+ const open = operations.watch ?? openRecursiveWatch;
39
+ const openPoller = operations.poll ?? openWatchPoller;
40
+ const platform = operations.platform ?? process.platform;
41
+ const createCaseIdentities = () => createFilesystemPathIdentityContext({
42
+ ...(operations.caseSensitive === undefined
43
+ ? {}
44
+ : { caseSensitive: operations.caseSensitive }),
45
+ platform,
46
+ throwOnRealpathError: false,
47
+ });
48
+ let caseIdentities = createCaseIdentities();
49
+ const directoryCaseSensitivity = new Map();
50
+ let pathIdentityMemosDirty = false;
51
+ /** Drop path facts after topology or ownership changes make them stale. */
52
+ const resetPathIdentityMemos = () => {
53
+ caseIdentities = createCaseIdentities();
54
+ directoryCaseSensitivity.clear();
55
+ pathIdentityMemosDirty = false;
56
+ };
57
+ /** Lexical event key under the nearest existing directory's case policy. */
58
+ const watchPathKey = (file) => {
59
+ const absolute = path.resolve(file);
60
+ if (platform !== "win32" && platform !== "darwin") {
61
+ return absolute;
62
+ }
63
+ let current = path.dirname(absolute);
64
+ const traversed = [];
65
+ let sensitive;
66
+ for (;;) {
67
+ const cacheKey = platform === "win32" ? current.toLowerCase() : current;
68
+ sensitive = directoryCaseSensitivity.get(cacheKey);
69
+ if (sensitive !== undefined)
70
+ break;
71
+ traversed.push(cacheKey);
72
+ try {
73
+ if (fs.statSync(current).isDirectory()) {
74
+ sensitive = caseIdentities.caseSensitive(current);
75
+ break;
76
+ }
77
+ }
78
+ catch {
79
+ // A missing suffix inherits the nearest existing ancestor's policy.
80
+ }
81
+ const parent = path.dirname(current);
82
+ if (parent === current) {
83
+ // The shared identity resolver uses the platform default when even the
84
+ // volume root cannot answer the read-only case-sensitivity probe.
85
+ sensitive = caseIdentities.caseSensitive(current);
86
+ break;
87
+ }
88
+ current = parent;
89
+ }
90
+ for (const directory of traversed) {
91
+ directoryCaseSensitivity.set(directory, sensitive);
92
+ }
93
+ return sensitive ? absolute : absolute.toLowerCase();
94
+ };
95
+ const unbindAlias = (alias, entry) => {
96
+ const indexed = aliases.get(alias);
97
+ indexed?.delete(entry);
98
+ if (indexed?.size === 0)
99
+ aliases.delete(alias);
100
+ };
101
+ const closeScope = (scope) => {
102
+ scopes.delete(watchPathKey(scope.root));
103
+ try {
104
+ scope.watcher?.close();
105
+ }
106
+ catch {
107
+ // The generation no longer trusts this scope, so cleanup is best effort.
108
+ }
109
+ scope.watcher = undefined;
110
+ };
111
+ const recordChange = (eventType, file) => {
112
+ const absolute = path.resolve(file);
113
+ const parent = path.dirname(absolute);
114
+ const direct = new Set();
115
+ const parents = new Set();
116
+ if (eventType === "rename") {
117
+ // Keep the active identity context until affected entries are removed.
118
+ // Their alias and scope indexes were built with that context; changing
119
+ // only the resolver here could make later events unable to reach them.
120
+ // `check()` resets the memos atomically if this topology change removes
121
+ // an entry, while unrelated renames leave still-valid indexes intact.
122
+ componentLinks.clear();
123
+ missingComponents.clear();
124
+ }
125
+ changeSequence += 1;
126
+ direct.add(watchPathKey(absolute));
127
+ parents.add(watchPathKey(parent));
128
+ for (const key of direct) {
129
+ componentLinks.delete(key);
130
+ missingComponents.delete(key);
131
+ changes.set(key, changeSequence);
132
+ }
133
+ for (const key of parents)
134
+ changes.set(key, changeSequence);
135
+ if (changes.size > MAX_CHANGE_HISTORY) {
136
+ changes.clear();
137
+ historyFloor = changeSequence;
138
+ }
139
+ return { direct, parent: parents };
140
+ };
27
141
  const remove = (entry) => {
28
142
  entries.delete(entry.file);
29
- watcher?.unwatch(entry.file);
143
+ pending.delete(entry);
144
+ polled.delete(entry);
145
+ for (const alias of entry.aliases)
146
+ unbindAlias(alias, entry);
147
+ entry.aliases.clear();
148
+ for (const alias of entry.renameAliases) {
149
+ const indexed = renameAliases.get(alias);
150
+ indexed?.delete(entry);
151
+ if (indexed?.size === 0)
152
+ renameAliases.delete(alias);
153
+ }
154
+ entry.renameAliases.clear();
155
+ for (const scope of entry.scopes) {
156
+ scope.entries.delete(entry);
157
+ if (scope.entries.size === 0 && !scope.pinned)
158
+ closeScope(scope);
159
+ }
160
+ entry.scopes.clear();
30
161
  for (const file of entry.links) {
31
162
  const link = links.get(file);
32
163
  link?.inputs.delete(entry);
33
164
  if (link?.inputs.size === 0)
34
165
  links.delete(file);
35
166
  }
167
+ entry.links.clear();
168
+ // Component resolution is only an observation-time optimization. Entries
169
+ // can introduce arbitrary missing ancestors, so discard the shared memo
170
+ // when one leaves rather than retaining its path components indefinitely.
171
+ componentLinks.clear();
172
+ missingComponents.clear();
173
+ if (links.size === 0)
174
+ linkIterator = undefined;
175
+ if (polled.size === 0)
176
+ pollIterator = undefined;
177
+ pathIdentityMemosDirty = true;
36
178
  };
37
179
  const check = (selected) => {
38
180
  const importers = new Set();
@@ -67,20 +209,50 @@ function createViteServeInputWatch() {
67
209
  remove(entry);
68
210
  }
69
211
  }
212
+ if (pathIdentityMemosDirty)
213
+ resetPathIdentityMemos();
214
+ updatePoller();
70
215
  if (server !== undefined && importers.size !== 0) {
71
216
  invalidateImporters(server, importers);
72
217
  sendFullReload(server);
73
218
  }
74
219
  };
75
- const enqueue = (file, observed) => {
220
+ const enqueue = (eventType, file) => {
76
221
  const absolute = path.resolve(file);
77
- const direct = entries.get(absolute);
78
- if (direct !== undefined && observed)
79
- direct.observed = true;
80
- for (const candidate of [absolute, path.dirname(absolute)]) {
81
- const entry = entries.get(candidate);
82
- if (entry !== undefined)
222
+ const eventKeys = recordChange(eventType, absolute);
223
+ for (const key of eventKeys.direct) {
224
+ for (const entry of aliases.get(key) ?? []) {
225
+ entry.changedAt = changeSequence;
83
226
  pending.add(entry);
227
+ }
228
+ }
229
+ for (const key of eventKeys.parent) {
230
+ for (const entry of aliases.get(key) ?? []) {
231
+ entry.changedAt = changeSequence;
232
+ pending.add(entry);
233
+ }
234
+ }
235
+ if (eventType === "rename") {
236
+ const exact = new Set();
237
+ for (const key of eventKeys.direct) {
238
+ for (const entry of renameAliases.get(key) ?? [])
239
+ exact.add(entry);
240
+ }
241
+ // Linux may report only the destination spelling of a directory rename.
242
+ // That spelling cannot be indexed before the move. Fall back to the
243
+ // renamed entry's parent only when no exact old spelling matched; the
244
+ // baseline check below still invalidates solely inputs that really moved.
245
+ const selected = exact;
246
+ if (selected.size === 0) {
247
+ for (const key of eventKeys.parent) {
248
+ for (const entry of renameAliases.get(key) ?? [])
249
+ selected.add(entry);
250
+ }
251
+ }
252
+ for (const entry of selected) {
253
+ entry.changedAt = changeSequence;
254
+ pending.add(entry);
255
+ }
84
256
  }
85
257
  if (pending.size === 0 || flushTimer !== undefined)
86
258
  return;
@@ -92,67 +264,294 @@ function createViteServeInputWatch() {
92
264
  }, 0);
93
265
  flushTimer.unref();
94
266
  };
95
- const ensureWatcher = () => {
96
- if (watcher !== undefined)
97
- return watcher;
98
- // Chokidar's persistent:false backend omits its native error listener on
99
- // Windows. Keep the owned subscription alive until dispose() closes it.
100
- const active = watch([], { depth: 0, ignoreInitial: false });
101
- watcher = active;
102
- // Initial add events compare compiler-time evidence too: an edit between
103
- // compilation and asynchronous watcher setup must not be missed.
104
- active.on("all", (event, file) => {
105
- if (watcher === active)
106
- enqueue(file, event === "add" || event === "addDir");
107
- });
108
- active.on("error", () => {
109
- if (watcher === active)
110
- failed = true;
111
- });
112
- poller = setInterval(() => {
113
- const selected = new Set([...entries.values()].filter((entry) => failed || entry.poll || !entry.observed));
267
+ const bindAlias = (entry, alias) => {
268
+ alias = watchPathKey(alias);
269
+ if (entry.aliases.has(alias))
270
+ return;
271
+ entry.aliases.add(alias);
272
+ let indexed = aliases.get(alias);
273
+ if (indexed === undefined) {
274
+ indexed = new Set();
275
+ aliases.set(alias, indexed);
276
+ }
277
+ indexed.add(entry);
278
+ };
279
+ const bindRenameAncestors = (entry, file) => {
280
+ let current = path.dirname(path.resolve(file));
281
+ for (;;) {
282
+ const key = watchPathKey(current);
283
+ if (!entry.renameAliases.has(key)) {
284
+ entry.renameAliases.add(key);
285
+ let indexed = renameAliases.get(key);
286
+ if (indexed === undefined) {
287
+ indexed = new Set();
288
+ renameAliases.set(key, indexed);
289
+ }
290
+ indexed.add(entry);
291
+ }
292
+ const parent = path.dirname(current);
293
+ if (parent === current)
294
+ return;
295
+ current = parent;
296
+ }
297
+ };
298
+ const ensureScope = (root, external, pinned = false) => {
299
+ root = path.resolve(root);
300
+ const key = watchPathKey(root);
301
+ let scope = scopes.get(key);
302
+ if (scope === undefined) {
303
+ if (external &&
304
+ [...scopes.values()].filter((candidate) => !candidate.pinned).length >=
305
+ MAX_EXTERNAL_WATCH_SCOPES)
306
+ return undefined;
307
+ // Opening a scope is itself an observation boundary. Advance the same
308
+ // sequence returned by begin() so an external scope first discovered
309
+ // after compilation cannot claim it was already live at that token when
310
+ // no filesystem event happened in between.
311
+ changeSequence += 1;
312
+ scope = {
313
+ entries: new Set(),
314
+ failed: false,
315
+ pinned,
316
+ root,
317
+ startedAt: changeSequence,
318
+ };
319
+ scopes.set(key, scope);
320
+ try {
321
+ const owned = scope;
322
+ scope.watcher = open(root, (eventType, file) => {
323
+ if (scopes.get(key) !== owned)
324
+ return;
325
+ if (file === null) {
326
+ resetPathIdentityMemos();
327
+ changeSequence += 1;
328
+ historyFloor = changeSequence;
329
+ changes.clear();
330
+ for (const candidate of owned.entries) {
331
+ candidate.changedAt = changeSequence;
332
+ pending.add(candidate);
333
+ }
334
+ scheduleFlush();
335
+ return;
336
+ }
337
+ enqueue(eventType, path.isAbsolute(file) ? file : path.resolve(root, file));
338
+ }, () => {
339
+ if (scopes.get(key) !== owned)
340
+ return;
341
+ owned.failed = true;
342
+ try {
343
+ owned.watcher?.close();
344
+ }
345
+ catch {
346
+ // The fallback owns validation now; a failed native handle is no
347
+ // longer useful, and cleanup must not replace that recovery.
348
+ }
349
+ owned.watcher = undefined;
350
+ for (const entry of owned.entries)
351
+ requirePolling(entry);
352
+ updatePoller();
353
+ });
354
+ if (scope.failed) {
355
+ // An injected or platform watcher may report failure synchronously
356
+ // during construction, before its handle can be assigned above.
357
+ try {
358
+ scope.watcher?.close();
359
+ }
360
+ catch { }
361
+ scope.watcher = undefined;
362
+ }
363
+ }
364
+ catch {
365
+ scope.failed = true;
366
+ }
367
+ }
368
+ else if (pinned) {
369
+ scope.pinned = true;
370
+ }
371
+ return scope;
372
+ };
373
+ const bindScope = (root, entry, external) => {
374
+ const scope = ensureScope(root, external);
375
+ if (scope === undefined)
376
+ return false;
377
+ scope.entries.add(entry);
378
+ entry.scopes.add(scope);
379
+ return !scope.failed;
380
+ };
381
+ function requirePolling(entry) {
382
+ entry.fallback = true;
383
+ if (polled.size === 0)
384
+ pollIterator = undefined;
385
+ polled.add(entry);
386
+ }
387
+ const scheduleFlush = () => {
388
+ if (pending.size === 0 || flushTimer !== undefined)
389
+ return;
390
+ flushTimer = setTimeout(() => {
391
+ flushTimer = undefined;
392
+ const selected = [...pending];
393
+ pending.clear();
394
+ check(selected);
395
+ }, 0);
396
+ flushTimer.unref();
397
+ };
398
+ function updatePoller() {
399
+ const needed = links.size !== 0 || polled.size !== 0;
400
+ if (!needed) {
401
+ poller?.close();
402
+ poller = undefined;
403
+ return;
404
+ }
405
+ if (poller !== undefined)
406
+ return;
407
+ poller = openPoller(() => {
408
+ const selected = new Set();
409
+ for (let count = 0; count < MAX_FALLBACK_PROBES_PER_TICK && polled.size !== 0; count += 1) {
410
+ pollIterator ??= polled.values();
411
+ let next = pollIterator.next();
412
+ if (next.done) {
413
+ pollIterator = polled.values();
414
+ next = pollIterator.next();
415
+ }
416
+ if (next.done)
417
+ break;
418
+ selected.add(next.value);
419
+ }
114
420
  // Files reached through one linked directory share one topology check.
115
421
  // Content edits remain event-driven; retargeting a junction does not
116
- // reliably emit an event on its previously watched descendants.
117
- for (const [file, link] of links) {
422
+ // reliably emit an event on its previously watched descendants. Reconcile
423
+ // a fixed-size slice as a safety net; ordinary retargets arrive at once
424
+ // through the project-root observer, while even an enormous dependency
425
+ // graph has constant idle CPU cost.
426
+ for (let count = 0; count < MAX_LINK_PROBES_PER_TICK && links.size !== 0; count += 1) {
427
+ linkIterator ??= links.entries();
428
+ let next = linkIterator.next();
429
+ if (next.done) {
430
+ linkIterator = links.entries();
431
+ next = linkIterator.next();
432
+ }
433
+ if (next.done)
434
+ break;
435
+ const [file, link] = next.value;
118
436
  const target = realpath(file);
119
437
  if (target !== link.target) {
120
438
  link.target = target;
121
439
  for (const entry of link.inputs) {
122
440
  selected.add(entry);
123
- entry.observed = false;
124
- active.unwatch(entry.file);
125
- active.add(entry.file);
126
441
  }
127
442
  }
128
443
  }
129
444
  check(selected);
130
- }, 500);
131
- poller.unref();
132
- return watcher;
445
+ });
446
+ }
447
+ const observe = (entry) => {
448
+ bindAlias(entry, entry.file);
449
+ bindRenameAncestors(entry, entry.file);
450
+ const root = projectRoot;
451
+ if (root !== undefined && containsPath(root, entry.file)) {
452
+ if (!bindScope(root, entry, false))
453
+ requirePolling(entry);
454
+ }
455
+ else {
456
+ const external = nearestExistingDirectory(entry.file);
457
+ if (external === undefined || !bindScope(external, entry, true))
458
+ requirePolling(entry);
459
+ }
460
+ const target = realpath(entry.file);
461
+ if (hasMultipleLinks(entry.file))
462
+ requirePolling(entry);
463
+ if (target !== undefined) {
464
+ bindAlias(entry, target);
465
+ bindRenameAncestors(entry, target);
466
+ if (!sameSpelling(entry.file, target)) {
467
+ let link = links.get(entry.file);
468
+ if (link === undefined) {
469
+ link = { target, inputs: new Set() };
470
+ if (links.size === 0)
471
+ linkIterator = undefined;
472
+ links.set(entry.file, link);
473
+ }
474
+ link.inputs.add(entry);
475
+ entry.links.add(entry.file);
476
+ if (root === undefined || !containsPath(root, target)) {
477
+ const targetRoot = nearestExistingDirectory(target);
478
+ if (targetRoot === undefined || !bindScope(targetRoot, entry, true))
479
+ requirePolling(entry);
480
+ }
481
+ }
482
+ }
483
+ if (root !== undefined) {
484
+ for (const linkedFile of linkedComponents(entry.file, root, componentLinks, missingComponents, watchPathKey)) {
485
+ bindAlias(entry, linkedFile);
486
+ let link = links.get(linkedFile);
487
+ if (link === undefined) {
488
+ link = { target: realpath(linkedFile), inputs: new Set() };
489
+ if (links.size === 0)
490
+ linkIterator = undefined;
491
+ links.set(linkedFile, link);
492
+ }
493
+ link.inputs.add(entry);
494
+ entry.links.add(linkedFile);
495
+ if (link.target === undefined)
496
+ requirePolling(entry);
497
+ else if (!containsPath(root, link.target) &&
498
+ !bindScope(link.target, entry, true))
499
+ requirePolling(entry);
500
+ }
501
+ }
133
502
  };
134
503
  return {
135
504
  attach(next) {
136
505
  server = next;
506
+ projectRoot = path.resolve(next.config?.root ?? process.cwd());
507
+ ensureScope(projectRoot, false, true);
508
+ },
509
+ begin() {
510
+ return changeSequence;
137
511
  },
138
512
  async dispose() {
139
513
  entries.clear();
514
+ aliases.clear();
515
+ renameAliases.clear();
140
516
  importerInputs.clear();
141
517
  pending.clear();
518
+ polled.clear();
142
519
  links.clear();
143
- if (poller !== undefined)
144
- clearInterval(poller);
520
+ componentLinks.clear();
521
+ missingComponents.clear();
522
+ changes.clear();
523
+ linkIterator = undefined;
524
+ pollIterator = undefined;
525
+ poller?.close();
145
526
  if (flushTimer !== undefined)
146
527
  clearTimeout(flushTimer);
147
528
  poller = undefined;
148
529
  flushTimer = undefined;
149
- const closing = watcher;
150
- watcher = undefined;
151
- failed = false;
152
- await closing?.close();
530
+ for (const scope of [...scopes.values()])
531
+ closeScope(scope);
532
+ resetPathIdentityMemos();
153
533
  // Retain the attached server across overlapping Vite restart containers.
154
534
  },
155
- replace(importer, inputs, failed = false) {
535
+ forget(importer) {
536
+ importer = path.resolve(importer);
537
+ const previous = importerInputs.get(importer);
538
+ if (previous === undefined)
539
+ return;
540
+ importerInputs.delete(importer);
541
+ for (const [file, key] of previous) {
542
+ const entry = entries.get(file);
543
+ const condition = entry?.conditions.get(key);
544
+ condition?.importers.delete(importer);
545
+ if (condition?.importers.size === 0)
546
+ entry?.conditions.delete(key);
547
+ if (entry?.conditions.size === 0)
548
+ remove(entry);
549
+ }
550
+ if (pathIdentityMemosDirty)
551
+ resetPathIdentityMemos();
552
+ updatePoller();
553
+ },
554
+ replace(importer, inputs, failed = false, startedAt) {
156
555
  if (server === undefined)
157
556
  return;
158
557
  importer = path.resolve(importer);
@@ -170,7 +569,8 @@ function createViteServeInputWatch() {
170
569
  ];
171
570
  }
172
571
  const current = new Map();
173
- const added = [];
572
+ const added = new Set();
573
+ const touched = new Set();
174
574
  for (const input of inputs) {
175
575
  const file = path.resolve(input.file);
176
576
  const evidence = input.evidence;
@@ -179,38 +579,20 @@ function createViteServeInputWatch() {
179
579
  let entry = entries.get(file);
180
580
  if (entry === undefined) {
181
581
  entry = {
582
+ aliases: new Set(),
583
+ changedAt: 0,
182
584
  file,
183
585
  conditions: new Map(),
184
- poll: false,
185
- observed: false,
586
+ fallback: false,
186
587
  links: new Set(),
588
+ renameAliases: new Set(),
589
+ scopes: new Set(),
187
590
  };
188
591
  entries.set(file, entry);
189
- added.push(file);
190
- const directory = path.dirname(file);
191
- const directoryTarget = realpath(directory);
192
- const fileTarget = realpath(file);
193
- const linked = [
194
- ...(directoryTarget !== undefined &&
195
- !sameSpelling(directory, directoryTarget)
196
- ? [directory]
197
- : []),
198
- ...(fileTarget !== undefined &&
199
- directoryTarget !== undefined &&
200
- !sameSpelling(fileTarget, path.join(directoryTarget, path.basename(file)))
201
- ? [file]
202
- : []),
203
- ];
204
- for (const linkedFile of linked) {
205
- let link = links.get(linkedFile);
206
- if (link === undefined) {
207
- link = { target: realpath(linkedFile), inputs: new Set() };
208
- links.set(linkedFile, link);
209
- }
210
- link.inputs.add(entry);
211
- entry.links.add(linkedFile);
212
- }
592
+ added.add(entry);
593
+ observe(entry);
213
594
  }
595
+ touched.add(entry);
214
596
  let condition = entry.conditions.get(key);
215
597
  if (condition === undefined) {
216
598
  condition = {
@@ -223,18 +605,6 @@ function createViteServeInputWatch() {
223
605
  entry.conditions.set(key, condition);
224
606
  }
225
607
  condition.importers.add(importer);
226
- const observation = evidence?.state?.codec === "predicates"
227
- ? evidence.state.observation
228
- : undefined;
229
- entry.poll ||=
230
- evidence?.missing === true ||
231
- evidence?.unavailable !== undefined ||
232
- (observation !== undefined &&
233
- observation.fileExists !== true &&
234
- observation.stat !== "file" &&
235
- observation.readFile?.ok !== true) ||
236
- (evidence?.state === undefined &&
237
- condition.baseline?.fileExists !== true);
238
608
  }
239
609
  for (const [file, key] of previous) {
240
610
  if (current.get(file) === key)
@@ -248,12 +618,133 @@ function createViteServeInputWatch() {
248
618
  remove(entry);
249
619
  }
250
620
  }
251
- importerInputs.set(importer, current);
252
- if (added.length !== 0)
253
- ensureWatcher().add(added);
621
+ if (pathIdentityMemosDirty)
622
+ resetPathIdentityMemos();
623
+ if (current.size === 0)
624
+ importerInputs.delete(importer);
625
+ else
626
+ importerInputs.set(importer, current);
627
+ // Registration and removal can each touch thousands of compiler inputs.
628
+ // Decide the one shared poller's state once per atomic replacement,
629
+ // rather than rescanning the whole graph once per input.
630
+ updatePoller();
631
+ // Watchers are live before this proof. It closes the compile-to-subscribe
632
+ // race without manufacturing one native subscription per input.
633
+ if (touched.size !== 0) {
634
+ if (startedAt === undefined || startedAt < historyFloor) {
635
+ check(touched);
636
+ }
637
+ else {
638
+ const raced = [];
639
+ for (const entry of touched) {
640
+ if (entry.fallback || entry.changedAt > startedAt) {
641
+ raced.push(entry);
642
+ continue;
643
+ }
644
+ const observedAfterCompile = added.has(entry) &&
645
+ (someSet(entry.aliases, (alias) => (changes.get(alias) ?? 0) > startedAt) ||
646
+ someSet(entry.renameAliases, (alias) => (changes.get(alias) ?? 0) > startedAt));
647
+ if (someSet(entry.scopes, (scope) => scope.failed ||
648
+ scope.startedAt > startedAt ||
649
+ observedAfterCompile)) {
650
+ raced.push(entry);
651
+ }
652
+ }
653
+ if (raced.length !== 0)
654
+ check(raced);
655
+ }
656
+ }
254
657
  },
255
658
  };
256
659
  }
660
+ const MAX_EXTERNAL_WATCH_SCOPES = 16;
661
+ const MAX_CHANGE_HISTORY = 100_000;
662
+ const MAX_FALLBACK_PROBES_PER_TICK = 64;
663
+ const MAX_LINK_PROBES_PER_TICK = 64;
664
+ /** Test a set without allocating a transient array on the registration path. */
665
+ function someSet(values, predicate) {
666
+ for (const value of values)
667
+ if (predicate(value))
668
+ return true;
669
+ return false;
670
+ }
671
+ function openWatchPoller(listener) {
672
+ const timer = setInterval(listener, 500);
673
+ timer.unref();
674
+ return { close: () => clearInterval(timer) };
675
+ }
676
+ function openRecursiveWatch(root, listener, onError) {
677
+ const watcher = fs.watch(root, { persistent: false, recursive: true }, (eventType, file) => listener(eventType, file === null ? null : String(file)));
678
+ watcher.on("error", onError);
679
+ return { close: () => watcher.close() };
680
+ }
681
+ function containsPath(root, file) {
682
+ const relative = path.relative(path.resolve(root), path.resolve(file));
683
+ return (relative === "" ||
684
+ (relative !== ".." &&
685
+ !relative.startsWith(`..${path.sep}`) &&
686
+ !path.isAbsolute(relative)));
687
+ }
688
+ function nearestExistingDirectory(file) {
689
+ let current = path.resolve(file);
690
+ try {
691
+ if (!fs.statSync(current).isDirectory())
692
+ current = path.dirname(current);
693
+ }
694
+ catch {
695
+ current = path.dirname(current);
696
+ }
697
+ for (;;) {
698
+ try {
699
+ if (fs.statSync(current).isDirectory()) {
700
+ return path.dirname(current) === current ? undefined : current;
701
+ }
702
+ }
703
+ catch {
704
+ // Keep climbing to the nearest directory a recursive watch can own.
705
+ }
706
+ const parent = path.dirname(current);
707
+ if (parent === current)
708
+ return undefined;
709
+ current = parent;
710
+ }
711
+ }
712
+ function linkedComponents(file, root, cached, missing, keyOf) {
713
+ const relative = path.relative(root, file);
714
+ if (relative === "" ||
715
+ relative === ".." ||
716
+ relative.startsWith(`..${path.sep}`) ||
717
+ path.isAbsolute(relative))
718
+ return [];
719
+ const output = [];
720
+ let current = path.resolve(root);
721
+ for (const component of relative.split(path.sep).slice(0, -1)) {
722
+ current = path.join(current, component);
723
+ const key = keyOf(current);
724
+ if (missing.has(key))
725
+ break;
726
+ const known = cached.get(key);
727
+ if (known !== undefined) {
728
+ if (known !== null)
729
+ output.push(current);
730
+ continue;
731
+ }
732
+ try {
733
+ if (fs.lstatSync(current).isSymbolicLink()) {
734
+ cached.set(key, realpath(current) ?? current);
735
+ output.push(current);
736
+ }
737
+ else {
738
+ cached.set(key, null);
739
+ }
740
+ }
741
+ catch {
742
+ missing.add(key);
743
+ break;
744
+ }
745
+ }
746
+ return output;
747
+ }
257
748
  function realpath(file) {
258
749
  try {
259
750
  return fs.realpathSync.native(file);
@@ -262,10 +753,20 @@ function realpath(file) {
262
753
  return undefined;
263
754
  }
264
755
  }
756
+ /** Whether writes can reach this input through an unobserved hardlink alias. */
757
+ function hasMultipleLinks(file) {
758
+ try {
759
+ const stats = fs.statSync(file);
760
+ return stats.isFile() && stats.nlink > 1;
761
+ }
762
+ catch {
763
+ return false;
764
+ }
765
+ }
265
766
  function sameSpelling(left, right) {
266
767
  return process.platform === "win32"
267
- ? left.toLowerCase() === right.toLowerCase()
268
- : left === right;
768
+ ? path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase()
769
+ : path.resolve(left) === path.resolve(right);
269
770
  }
270
771
  /**
271
772
  * Invalidate every module-graph node of the registered importers so the next