Skip to content

Commit c49c03c

Browse files
authored
feat: watch-suspend (#2430)
1 parent 62bad72 commit c49c03c

File tree

8 files changed

+228
-10
lines changed

8 files changed

+228
-10
lines changed

.changeset/small-pets-eat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@rspack/core": patch
3+
---
4+
5+
Support `suspend` and `resume` in Watching

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ dist
163163

164164
!packages/rspack/tests/configCases/target
165165
packages/rspack/tests/js
166+
packages/rspack/tests/fixtures/temp-*
166167
packages/rspack-dev-server/.test-temp
167168

168169
# Ignore local integrate debug file

packages/rspack/jest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ module.exports = {
66
"<rootDir>/tests/*.basictest.ts",
77
"<rootDir>/tests/*.longtest.ts",
88
"<rootDir>/tests/*.unittest.ts",
9-
"<rootDir>/tests/copyPlugin/*.test.js"
9+
"<rootDir>/tests/copyPlugin/*.test.js",
10+
"<rootDir>/tests/WatchSuspend.test.js"
1011
],
1112
testTimeout: 120000,
1213
cache: false,

packages/rspack/src/compiler.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -456,14 +456,10 @@ class Compiler {
456456

457457
watch(
458458
watchOptions: WatchOptions,
459-
handler: (error: Error, stats: Stats) => void
459+
handler: (error: Error, stats?: Stats) => Watching
460460
): Watching {
461461
if (this.running) {
462-
return handler(
463-
new ConcurrentCompilationError(),
464-
// @ts-expect-error
465-
null
466-
) as unknown as Watching;
462+
return handler(new ConcurrentCompilationError());
467463
}
468464
this.running = true;
469465
this.watchMode = true;

packages/rspack/src/multiWatching.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ class MultiWatching {
7070
}
7171
);
7272
}
73+
74+
suspend() {
75+
for (const watching of this.watchings) {
76+
watching.suspend();
77+
}
78+
}
79+
80+
resume() {
81+
for (const watching of this.watchings) {
82+
watching.resume();
83+
}
84+
}
7385
}
7486

7587
export default MultiWatching;

packages/rspack/src/util/fs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface Watcher {
77
getAggregatedRemovals?(): Set<string>; // get current aggregated removals that have not yet send to callback
88
getFileTimeInfoEntries?(): Map<string, FileSystemInfoEntry | "ignore">; // get info about files
99
getContextTimeInfoEntries?(): Map<string, FileSystemInfoEntry | "ignore">; // get info about directories
10-
getInfo?(): WatcherInfo; // get info about timestamps and changes
10+
getInfo(): WatcherInfo; // get info about timestamps and changes
1111
}
1212

1313
export interface WatcherInfo {

packages/rspack/src/watching.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* https://github.com/webpack/webpack/blob/main/LICENSE
99
*/
1010
import { Callback } from "tapable";
11-
import type { Compilation, Compiler } from ".";
11+
import type { Compiler } from ".";
1212
import { Stats } from ".";
1313
import { WatchOptions } from "./config";
1414
import { FileSystemInfoEntry, Watcher } from "./util/fs";
@@ -35,6 +35,7 @@ class Watching {
3535
#closed: boolean;
3636
#collectedChangedFiles?: Set<string>;
3737
#collectedRemovedFiles?: Set<string>;
38+
suspended: boolean;
3839

3940
constructor(
4041
compiler: Compiler,
@@ -54,6 +55,7 @@ class Watching {
5455
this.#closed = false;
5556
this.watchOptions = watchOptions;
5657
this.handler = handler;
58+
this.suspended = false;
5759

5860
process.nextTick(() => {
5961
if (this.#initial) this.#invalidate();
@@ -195,7 +197,7 @@ class Watching {
195197
// @ts-expect-error
196198
this.#mergeWithCollected(changedFiles, removedFiles);
197199
// @ts-expect-error
198-
if (this.isBlocked() && (this.blocked = true)) {
200+
if (this.suspended || (this.isBlocked() && (this.blocked = true))) {
199201
return;
200202
}
201203

@@ -218,6 +220,14 @@ class Watching {
218220
} else if (!this.lastWatcherStartTime) {
219221
this.lastWatcherStartTime = Date.now();
220222
}
223+
224+
if (changedFiles && removedFiles) {
225+
this.#mergeWithCollected(changedFiles, removedFiles);
226+
} else if (this.pausedWatcher) {
227+
const { changes, removals } = this.pausedWatcher.getInfo();
228+
this.#mergeWithCollected(changes, removals);
229+
}
230+
221231
const modifiedFiles = (this.compiler.modifiedFiles =
222232
this.#collectedChangedFiles);
223233
const deleteFiles = (this.compiler.removedFiles =
@@ -269,6 +279,7 @@ class Watching {
269279
if (error) {
270280
return handleError(error);
271281
}
282+
272283
const cbs = this.callbacks;
273284
this.callbacks = [];
274285

@@ -316,6 +327,17 @@ class Watching {
316327
}
317328
}
318329
}
330+
331+
suspend() {
332+
this.suspended = true;
333+
}
334+
335+
resume() {
336+
if (this.suspended) {
337+
this.suspended = false;
338+
this.#invalidate();
339+
}
340+
}
319341
}
320342

321343
export default Watching;
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copy from https://github.com/webpack/webpack/blob/main/test/WatchSuspend.test.js
2+
"use strict";
3+
4+
const path = require("path");
5+
const fs = require("fs");
6+
7+
describe("WatchSuspend", () => {
8+
if (process.env.NO_WATCH_TESTS) {
9+
it.skip("long running tests excluded", () => {});
10+
return;
11+
}
12+
13+
jest.setTimeout(5000);
14+
15+
describe("suspend and resume watcher", () => {
16+
const fixturePath = path.join(
17+
__dirname,
18+
"fixtures",
19+
"temp-watch-" + Date.now()
20+
);
21+
const filePath = path.join(fixturePath, "file.js");
22+
const file2Path = path.join(fixturePath, "file2.js");
23+
const file3Path = path.join(fixturePath, "file3.js");
24+
const outputPath = path.join(__dirname, "js/WatchSuspend");
25+
const outputFile = path.join(outputPath, "bundle.js");
26+
let compiler = null;
27+
let watching = null;
28+
let onChange = null;
29+
30+
beforeAll(() => {
31+
try {
32+
fs.mkdirSync(fixturePath);
33+
} catch (e) {
34+
// skip
35+
}
36+
try {
37+
fs.writeFileSync(filePath, "'foo'", "utf-8");
38+
fs.writeFileSync(file2Path, "'file2'", "utf-8");
39+
fs.writeFileSync(file3Path, "'file3'", "utf-8");
40+
} catch (e) {
41+
// skip
42+
}
43+
const { rspack: webpack } = require("../");
44+
compiler = webpack({
45+
mode: "development",
46+
entry: filePath,
47+
output: {
48+
path: outputPath,
49+
filename: "bundle.js"
50+
}
51+
});
52+
watching = compiler.watch({ aggregateTimeout: 50, poll: 1000 }, () => {});
53+
54+
compiler.hooks.done.tap("WatchSuspendTest", () => {
55+
if (onChange) onChange();
56+
});
57+
});
58+
59+
afterAll(() => {
60+
watching.close();
61+
compiler = null;
62+
try {
63+
fs.unlinkSync(filePath);
64+
} catch (e) {
65+
// skip
66+
}
67+
try {
68+
fs.rmdirSync(fixturePath);
69+
} catch (e) {
70+
// skip
71+
}
72+
});
73+
74+
it("should compile successfully", done => {
75+
onChange = () => {
76+
expect(fs.readFileSync(outputFile, "utf-8")).toContain("'foo'");
77+
onChange = null;
78+
done();
79+
};
80+
});
81+
82+
it("should suspend compilation", done => {
83+
onChange = jest.fn();
84+
watching.suspend();
85+
fs.writeFileSync(filePath, "'bar'", "utf-8");
86+
setTimeout(() => {
87+
expect(onChange.mock.calls.length).toBe(0);
88+
onChange = null;
89+
done();
90+
}, 1000);
91+
});
92+
93+
it("should resume compilation", done => {
94+
onChange = () => {
95+
expect(fs.readFileSync(outputFile, "utf-8")).toContain("'bar'");
96+
onChange = null;
97+
done();
98+
};
99+
watching.resume();
100+
});
101+
102+
for (const changeBefore of [true])
103+
for (const delay of [200]) {
104+
// eslint-disable-next-line no-loop-func
105+
it(`should not ignore changes during resumed compilation (changeBefore: ${changeBefore}, delay: ${delay}ms)`, async () => {
106+
// aggregateTimeout must be long enough for this test
107+
// So set-up new watcher and wait when initial compilation is done
108+
await new Promise(resolve => {
109+
watching.close(() => {
110+
watching = compiler.watch(
111+
{ aggregateTimeout: 1000, poll: 1000 },
112+
() => {
113+
resolve();
114+
}
115+
);
116+
});
117+
});
118+
return new Promise(resolve => {
119+
if (changeBefore) fs.writeFileSync(filePath, "'bar'", "utf-8");
120+
setTimeout(() => {
121+
watching.suspend();
122+
fs.writeFileSync(filePath, "'baz'", "utf-8");
123+
124+
onChange = "throw";
125+
setTimeout(() => {
126+
onChange = () => {
127+
expect(fs.readFileSync(outputFile, "utf-8")).toContain(
128+
"'baz'"
129+
);
130+
expect(
131+
compiler.modifiedFiles &&
132+
Array.from(compiler.modifiedFiles).sort()
133+
).toEqual([filePath]);
134+
expect(
135+
compiler.removedFiles && Array.from(compiler.removedFiles)
136+
).toEqual([]);
137+
onChange = null;
138+
resolve();
139+
};
140+
watching.resume();
141+
}, delay);
142+
}, 200);
143+
});
144+
});
145+
}
146+
147+
it("should not drop changes when suspended", done => {
148+
const aggregateTimeout = 50;
149+
// Trigger initial compilation with file2.js (assuming correct)
150+
fs.writeFileSync(
151+
filePath,
152+
'require("./file2.js"); require("./file3.js")',
153+
"utf-8"
154+
);
155+
156+
onChange = () => {
157+
// Initial compilation is done, start the test
158+
watching.suspend();
159+
160+
// Trigger the first change (works as expected):
161+
fs.writeFileSync(file2Path, "'foo'", "utf-8");
162+
163+
// Trigger the second change _after_ aggregation timeout of the first
164+
setTimeout(() => {
165+
fs.writeFileSync(file3Path, "'bar'", "utf-8");
166+
167+
// Wait when the file3 edit is settled and re-compile
168+
setTimeout(() => {
169+
watching.resume();
170+
171+
onChange = () => {
172+
onChange = null;
173+
expect(fs.readFileSync(outputFile, "utf-8")).toContain("'bar'");
174+
done();
175+
};
176+
}, 200);
177+
}, aggregateTimeout + 50);
178+
};
179+
});
180+
});
181+
});

0 commit comments

Comments
 (0)