Skip to content

Commit 80b6952

Browse files
perf: improve some cases
1 parent 6f582c7 commit 80b6952

4 files changed

Lines changed: 106 additions & 55 deletions

File tree

.changeset/olive-onions-yawn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"watchpack": patch
3+
---
4+
5+
Improve perfomance for ignored and improve perfomance for reduce plan.

lib/index.js

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,8 @@ const watchEventSource = require("./watchEventSource");
6060
*/
6161
function addWatchersToSet(watchers, set) {
6262
for (const ww of watchers) {
63-
const w = ww.watcher;
64-
if (!set.has(w.directoryWatcher)) {
65-
set.add(w.directoryWatcher);
66-
}
63+
// Set.add is already idempotent, so skip the redundant has() probe.
64+
set.add(ww.watcher.directoryWatcher);
6765
}
6866
}
6967

@@ -79,27 +77,44 @@ const stringToRegexp = (ignored) => {
7977
return `${source.slice(0, -1)}(?:$|\\/)`;
8078
};
8179

80+
/**
81+
* Normalizes path separators for regex testing. `String.prototype.replace`
82+
* always allocates a new string, even when the pattern finds nothing; for
83+
* POSIX paths (the common case) that allocation is pure overhead. Check for
84+
* a backslash with `indexOf` first so we skip the copy on paths that are
85+
* already normalized.
86+
* @param {string} item item
87+
* @returns {string} item with backslashes normalized to forward slashes
88+
*/
89+
const normalizeSeparators = (item) =>
90+
item.includes("\\") ? item.replace(/\\/g, "/") : item;
91+
8292
/**
8393
* @param {Ignored=} ignored ignored
8494
* @returns {(item: string) => boolean} ignored to function
8595
*/
8696
const ignoredToFunction = (ignored) => {
8797
if (Array.isArray(ignored)) {
88-
const stringRegexps = ignored.map((i) => stringToRegexp(i)).filter(Boolean);
98+
const stringRegexps =
99+
/** @type {string[]} */
100+
(ignored.map((i) => stringToRegexp(i)).filter(Boolean));
89101
if (stringRegexps.length === 0) {
90102
return () => false;
91103
}
92-
const regexp = new RegExp(stringRegexps.join("|"));
93-
return (item) => regexp.test(item.replace(/\\/g, "/"));
104+
const regexp =
105+
stringRegexps.length === 1
106+
? new RegExp(stringRegexps[0])
107+
: new RegExp(stringRegexps.join("|"));
108+
return (item) => regexp.test(normalizeSeparators(item));
94109
} else if (typeof ignored === "string") {
95110
const stringRegexp = stringToRegexp(ignored);
96111
if (!stringRegexp) {
97112
return () => false;
98113
}
99114
const regexp = new RegExp(stringRegexp);
100-
return (item) => regexp.test(item.replace(/\\/g, "/"));
115+
return (item) => regexp.test(normalizeSeparators(item));
101116
} else if (ignored instanceof RegExp) {
102-
return (item) => ignored.test(item.replace(/\\/g, "/"));
117+
return (item) => ignored.test(normalizeSeparators(item));
103118
} else if (typeof ignored === "function") {
104119
return ignored;
105120
} else if (ignored) {
@@ -463,8 +478,10 @@ class Watchpack extends EventEmitter {
463478
/** @type {Record<string, number>} */
464479
const obj = Object.create(null);
465480
for (const w of directoryWatchers) {
481+
// getTimes() returns a prototype-less object, so for...in is safe
482+
// and avoids the throwaway array that Object.keys would allocate.
466483
const times = w.getTimes();
467-
for (const file of Object.keys(times)) obj[file] = times[file];
484+
for (const file in times) obj[file] = times[file];
468485
}
469486
return obj;
470487
}

lib/reducePlan.js

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -67,45 +67,66 @@ module.exports = (plan, limit) => {
6767
}
6868
}
6969
}
70-
// Reduce until limit reached
71-
while (currentCount > limit) {
72-
// Select node that helps reaching the limit most effectively without overmerging
73-
const overLimit = currentCount - limit;
74-
let bestNode;
75-
let bestCost = Infinity;
70+
// Reduce until limit reached. When no reduction is needed at all, skip
71+
// building the candidate set entirely to avoid paying for the setup on the
72+
// common fast path.
73+
if (currentCount > limit) {
74+
// Pre-filter candidate nodes so the inner selection loop skips structural
75+
// non-candidates entirely. `children` length and parent presence are
76+
// fixed after tree construction; only `entries` can change (it can only
77+
// decrease), so a node that fails the `entries` check in a later round
78+
// is simply skipped via `continue`. When we merge a subtree we drop the
79+
// descendants from the candidate set to keep it shrinking over
80+
// iterations.
81+
/** @type {Set<TreeNode<T>>} */
82+
const candidates = new Set();
7683
for (const node of treeMap.values()) {
77-
if (node.entries <= 1 || !node.children || !node.parent) continue;
84+
if (!node.parent || !node.children) continue;
7885
if (node.children.length === 0) continue;
7986
if (node.children.length === 1 && !node.value) continue;
80-
// Try to select the node with has just a bit more entries than we need to reduce
81-
// When just a bit more is over 30% over the limit,
82-
// also consider just a bit less entries then we need to reduce
83-
const cost =
84-
node.entries - 1 >= overLimit
85-
? node.entries - 1 - overLimit
86-
: overLimit - node.entries + 1 + limit * 0.3;
87-
if (cost < bestCost) {
88-
bestNode = node;
89-
bestCost = cost;
90-
}
91-
}
92-
if (!bestNode) break;
93-
// Merge all children
94-
const reduction = bestNode.entries - 1;
95-
bestNode.active = true;
96-
bestNode.entries = 1;
97-
currentCount -= reduction;
98-
let { parent } = bestNode;
99-
while (parent) {
100-
parent.entries -= reduction;
101-
parent = parent.parent;
87+
candidates.add(node);
10288
}
103-
const queue = new Set(bestNode.children);
104-
for (const node of queue) {
105-
node.active = false;
106-
node.entries = 0;
107-
if (node.children) {
108-
for (const child of node.children) queue.add(child);
89+
const costBias = limit * 0.3;
90+
while (currentCount > limit) {
91+
// Select node that helps reaching the limit most effectively without overmerging
92+
const overLimit = currentCount - limit;
93+
let bestNode;
94+
let bestCost = Infinity;
95+
for (const node of candidates) {
96+
if (node.entries <= 1) continue;
97+
// Try to select the node with has just a bit more entries than we need to reduce
98+
// When just a bit more is over 30% over the limit,
99+
// also consider just a bit less entries then we need to reduce
100+
const diff = node.entries - 1 - overLimit;
101+
const cost = diff >= 0 ? diff : -diff + costBias;
102+
if (cost < bestCost) {
103+
bestNode = node;
104+
bestCost = cost;
105+
// A cost of 0 means the merge reduces exactly to the limit;
106+
// no further candidate can improve on that, so stop scanning.
107+
if (cost === 0) break;
108+
}
109+
}
110+
if (!bestNode) break;
111+
// Merge all children
112+
const reduction = bestNode.entries - 1;
113+
bestNode.active = true;
114+
bestNode.entries = 1;
115+
candidates.delete(bestNode);
116+
currentCount -= reduction;
117+
let { parent } = bestNode;
118+
while (parent) {
119+
parent.entries -= reduction;
120+
parent = parent.parent;
121+
}
122+
const queue = new Set(bestNode.children);
123+
for (const node of queue) {
124+
node.active = false;
125+
node.entries = 0;
126+
candidates.delete(node);
127+
if (node.children) {
128+
for (const child of node.children) queue.add(child);
129+
}
109130
}
110131
}
111132
}

lib/watchEventSource.js

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,17 @@ function createEPERMError(filePath) {
6161
* @returns {(type: "rename" | "change", filename: string) => void} handler of change event
6262
*/
6363
function createHandleChangeEvent(watcher, filePath, handleChangeEvent) {
64+
// path.basename(filePath) is invariant for the lifetime of the watcher,
65+
// so compute it once rather than on every dispatched event.
66+
const ownBasename = path.basename(filePath);
6467
return (type, filename) => {
6568
// TODO: After Node.js v22, fs.watch(dir) and deleting a dir will trigger the rename change event.
6669
// Here we just ignore it and keep the same behavior as before v22
6770
// https://github.com/libuv/libuv/pull/4376
6871
if (
6972
type === "rename" &&
7073
path.isAbsolute(filename) &&
71-
path.basename(filename) === path.basename(filePath)
74+
path.basename(filename) === ownBasename
7275
) {
7376
if (!IS_OSX) {
7477
// Before v22, windows will throw EPERM error
@@ -429,16 +432,21 @@ module.exports.watch = (filePath) => {
429432
directWatcher.add(watcher);
430433
return watcher;
431434
}
432-
let current = filePath;
433-
for (;;) {
434-
const recursiveWatcher = recursiveWatchers.get(current);
435-
if (recursiveWatcher !== undefined) {
436-
recursiveWatcher.add(filePath, watcher);
437-
return watcher;
435+
// Only platforms with recursive fs.watch ever populate recursiveWatchers,
436+
// so skip the entire parent walk when the map is empty (always the case
437+
// on Linux and the common case before the watcher limit is reached).
438+
if (recursiveWatchers.size !== 0) {
439+
let current = filePath;
440+
for (;;) {
441+
const recursiveWatcher = recursiveWatchers.get(current);
442+
if (recursiveWatcher !== undefined) {
443+
recursiveWatcher.add(filePath, watcher);
444+
return watcher;
445+
}
446+
const parent = path.dirname(current);
447+
if (parent === current) break;
448+
current = parent;
438449
}
439-
const parent = path.dirname(current);
440-
if (parent === current) break;
441-
current = parent;
442450
}
443451
// Queue up watcher for creation
444452
pendingWatchers.set(watcher, filePath);

0 commit comments

Comments
 (0)