Skip to content

Commit ab5b12d

Browse files
authored
perf: abort stale type-checks on fast recompilations (#764)
When user saves changes faster than type-checking process, we can end-up with a queue of type-checks that could be skipped. This commit ensures that we queue only 1 type-check per compilation and abort previous.
1 parent 6155272 commit ab5b12d

File tree

8 files changed

+115
-29
lines changed

8 files changed

+115
-29
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,15 @@
6363
"fs-extra": "^10.0.0",
6464
"memfs": "^3.4.1",
6565
"minimatch": "^3.0.4",
66+
"node-abort-controller": "^3.0.1",
6667
"schema-utils": "^3.1.1",
6768
"semver": "^7.3.5",
6869
"tapable": "^2.2.1"
6970
},
7071
"peerDependencies": {
7172
"typescript": ">3.6.0",
72-
"webpack": "^5.11.0",
73-
"vue-template-compiler": "*"
73+
"vue-template-compiler": "*",
74+
"webpack": "^5.11.0"
7475
},
7576
"peerDependenciesMeta": {
7677
"vue-template-compiler": {

src/hooks/tap-done-to-async-get-issues.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,20 @@ function tapDoneToAsyncGetIssues(
3939
}
4040

4141
issues = await issuesPromise;
42-
debug('Got issues from getIssuesWorker.', issues?.length);
4342
} catch (error) {
4443
hooks.error.call(error, stats.compilation);
4544
return;
4645
}
4746

48-
if (!issues) {
49-
// some error has been thrown or it was canceled
47+
if (
48+
!issues || // some error has been thrown
49+
state.issuesPromise !== issuesPromise // we have a new request - don't show results for the old one
50+
) {
5051
return;
5152
}
5253

54+
debug(`Got ${issues?.length || 0} issues from getIssuesWorker.`);
55+
5356
// filter list of issues by provided issue predicate
5457
issues = issues.filter(config.issue.predicate);
5558

src/hooks/tap-error-to-log-message.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type webpack from 'webpack';
44
import type { ForkTsCheckerWebpackPluginConfig } from '../plugin-config';
55
import { getPluginHooks } from '../plugin-hooks';
66
import { RpcExitError } from '../rpc';
7+
import { AbortError } from '../utils/async/abort-error';
78

89
function tapErrorToLogMessage(
910
compiler: webpack.Compiler,
@@ -12,6 +13,10 @@ function tapErrorToLogMessage(
1213
const hooks = getPluginHooks(compiler);
1314

1415
hooks.error.tap('ForkTsCheckerWebpackPlugin', (error) => {
16+
if (error instanceof AbortError) {
17+
return;
18+
}
19+
1520
config.logger.error(String(error));
1621

1722
if (error instanceof RpcExitError) {

src/hooks/tap-start-to-run-workers.ts

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { AbortController } from 'node-abort-controller';
12
import type * as webpack from 'webpack';
23

34
import type { FilesChange } from '../files-change';
4-
import { consumeFilesChange } from '../files-change';
5+
import { aggregateFilesChanges, consumeFilesChange } from '../files-change';
56
import { getInfrastructureLogger } from '../infrastructure-logger';
67
import type { ForkTsCheckerWebpackPluginConfig } from '../plugin-config';
78
import { getPluginHooks } from '../plugin-hooks';
@@ -59,49 +60,91 @@ function tapStartToRunWorkers(
5960
return;
6061
}
6162

63+
// get current iteration number
6264
const iteration = ++state.iteration;
6365

64-
let change: FilesChange = {};
66+
// abort previous iteration
67+
if (state.abortController) {
68+
debug(`Aborting iteration ${iteration - 1}.`);
69+
state.abortController.abort();
70+
}
71+
72+
// create new abort controller for the new iteration
73+
const abortController = new AbortController();
74+
state.abortController = abortController;
75+
76+
let filesChange: FilesChange = {};
6577

6678
if (state.watching) {
67-
change = consumeFilesChange(compiler);
79+
filesChange = consumeFilesChange(compiler);
6880
log(
6981
[
7082
'Calling reporter service for incremental check.',
71-
` Changed files: ${JSON.stringify(change.changedFiles)}`,
72-
` Deleted files: ${JSON.stringify(change.deletedFiles)}`,
83+
` Changed files: ${JSON.stringify(filesChange.changedFiles)}`,
84+
` Deleted files: ${JSON.stringify(filesChange.deletedFiles)}`,
7385
].join('\n')
7486
);
7587
} else {
7688
log('Calling reporter service for single check.');
7789
}
7890

79-
change = await hooks.start.promise(change, compilation);
91+
filesChange = await hooks.start.promise(filesChange, compilation);
92+
let aggregatedFilesChange = filesChange;
93+
if (state.aggregatedFilesChange) {
94+
aggregatedFilesChange = aggregateFilesChanges([aggregatedFilesChange, filesChange]);
95+
debug(
96+
[
97+
`Aggregating with previous files change, iteration ${iteration}.`,
98+
` Changed files: ${JSON.stringify(aggregatedFilesChange.changedFiles)}`,
99+
` Deleted files: ${JSON.stringify(aggregatedFilesChange.deletedFiles)}`,
100+
].join('\n')
101+
);
102+
}
103+
state.aggregatedFilesChange = aggregatedFilesChange;
104+
105+
// submit one at a time for a single compiler
106+
state.issuesPromise = (state.issuesPromise || Promise.resolve())
107+
// resolve to undefined on error
108+
.catch(() => undefined)
109+
.then(() => {
110+
// early return
111+
if (abortController.signal.aborted) {
112+
return undefined;
113+
}
114+
115+
debug(`Submitting the getIssuesWorker to the pool, iteration ${iteration}.`);
116+
return issuesPool.submit(async () => {
117+
try {
118+
debug(`Running the getIssuesWorker, iteration ${iteration}.`);
119+
const issues = await getIssuesWorker(aggregatedFilesChange, state.watching);
120+
if (state.aggregatedFilesChange === aggregatedFilesChange) {
121+
state.aggregatedFilesChange = undefined;
122+
}
123+
if (state.abortController === abortController) {
124+
state.abortController = undefined;
125+
}
126+
return issues;
127+
} catch (error) {
128+
hooks.error.call(error, compilation);
129+
return undefined;
130+
} finally {
131+
debug(`The getIssuesWorker finished its job, iteration ${iteration}.`);
132+
}
133+
}, abortController.signal);
134+
});
80135

81-
debug(`Submitting the getIssuesWorker to the pool, iteration ${iteration}.`);
82-
state.issuesPromise = issuesPool.submit(async () => {
83-
try {
84-
debug(`Running the getIssuesWorker, iteration ${iteration}.`);
85-
return await getIssuesWorker(change, state.watching);
86-
} catch (error) {
87-
hooks.error.call(error, compilation);
88-
return undefined;
89-
} finally {
90-
debug(`The getIssuesWorker finished its job, iteration ${iteration}.`);
91-
}
92-
});
93136
debug(`Submitting the getDependenciesWorker to the pool, iteration ${iteration}.`);
94137
state.dependenciesPromise = dependenciesPool.submit(async () => {
95138
try {
96139
debug(`Running the getDependenciesWorker, iteration ${iteration}.`);
97-
return await getDependenciesWorker(change);
140+
return await getDependenciesWorker(filesChange);
98141
} catch (error) {
99142
hooks.error.call(error, compilation);
100143
return undefined;
101144
} finally {
102145
debug(`The getDependenciesWorker finished its job, iteration ${iteration}.`);
103146
}
104-
});
147+
}); // don't pass abortController.signal because getDependencies() is blocking
105148
});
106149
}
107150

src/plugin-state.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import type { AbortController } from 'node-abort-controller';
12
import type { FullTap } from 'tapable';
23

4+
import type { FilesChange } from './files-change';
35
import type { FilesMatch } from './files-match';
46
import type { Issue } from './issue';
57

68
interface ForkTsCheckerWebpackPluginState {
79
issuesPromise: Promise<Issue[] | undefined>;
810
dependenciesPromise: Promise<FilesMatch | undefined>;
11+
abortController: AbortController | undefined;
12+
aggregatedFilesChange: FilesChange | undefined;
913
lastDependencies: FilesMatch | undefined;
1014
watching: boolean;
1115
initialized: boolean;
@@ -17,6 +21,8 @@ function createPluginState(): ForkTsCheckerWebpackPluginState {
1721
return {
1822
issuesPromise: Promise.resolve(undefined),
1923
dependenciesPromise: Promise.resolve(undefined),
24+
abortController: undefined,
25+
aggregatedFilesChange: undefined,
2026
lastDependencies: undefined,
2127
watching: false,
2228
initialized: false,

src/utils/async/abort-error.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { AbortSignal } from 'node-abort-controller';
2+
3+
class AbortError extends Error {
4+
constructor(message = 'Task aborted.') {
5+
super(message);
6+
this.name = 'AbortError';
7+
}
8+
9+
static throwIfAborted(signal: AbortSignal | undefined) {
10+
if (signal?.aborted) {
11+
throw new AbortError();
12+
}
13+
}
14+
}
15+
16+
export { AbortError };

src/utils/async/pool.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
type Task<T> = () => Promise<T>;
1+
import type { AbortSignal } from 'node-abort-controller';
2+
3+
import { AbortError } from './abort-error';
4+
5+
type Task<T> = (signal?: AbortSignal) => Promise<T>;
26

37
interface Pool {
4-
submit<T>(task: Task<T>): Promise<T>;
8+
submit<T>(task: Task<T>, signal?: AbortSignal): Promise<T>;
59
size: number;
610
readonly pending: number;
711
readonly drained: Promise<void>;
@@ -11,12 +15,15 @@ function createPool(size: number): Pool {
1115
let pendingPromises: Promise<unknown>[] = [];
1216

1317
const pool = {
14-
async submit<T>(task: Task<T>): Promise<T> {
18+
async submit<T>(task: Task<T>, signal?: AbortSignal): Promise<T> {
1519
while (pendingPromises.length >= pool.size) {
20+
AbortError.throwIfAborted(signal);
1621
await Promise.race(pendingPromises).catch(() => undefined);
1722
}
1823

19-
const taskPromise = task().finally(() => {
24+
AbortError.throwIfAborted(signal);
25+
26+
const taskPromise = task(signal).finally(() => {
2027
pendingPromises = pendingPromises.filter(
2128
(pendingPromise) => pendingPromise !== taskPromise
2229
);

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5804,6 +5804,11 @@ nerf-dart@^1.0.0:
58045804
resolved "https://registry.yarnpkg.com/nerf-dart/-/nerf-dart-1.0.0.tgz#e6dab7febf5ad816ea81cf5c629c5a0ebde72c1a"
58055805
integrity sha1-5tq3/r9a2Bbqgc9cYpxaDr3nLBo=
58065806

5807+
node-abort-controller@^3.0.1:
5808+
version "3.0.1"
5809+
resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e"
5810+
integrity sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==
5811+
58075812
node-emoji@^1.10.0:
58085813
version "1.11.0"
58095814
resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c"

0 commit comments

Comments
 (0)