Skip to content

Commit fb2efec

Browse files
Add abortSignal option to Node.js API for cancellation support (#9213)
1 parent 84f2c6b commit fb2efec

7 files changed

Lines changed: 278 additions & 1 deletion

File tree

.changeset/fifty-signs-cry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"stylelint": minor
3+
---
4+
5+
Added: experimental `abortSignal` option to Node.js API for cancellation support

docs/user-guide/node-api.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ const result = await stylelint.lint(options);
1010

1111
In addition to the [standard options](options.md), the Node API accepts:
1212

13+
### `abortSignal`
14+
15+
> [!WARNING]
16+
> This option is **experimental**, and might change in a future release.
17+
18+
An [`AbortSignal`](https://nodejs.org/api/globals.html#class-abortsignal) to cancel the lint operation. When the signal is aborted, `stylelint.lint()` will reject with the signal's reason.
19+
20+
This is useful for long-running consumers like language servers that need to cancel in-flight linting when a document changes.
21+
22+
```js
23+
const controller = new AbortController();
24+
25+
const resultPromise = stylelint.lint({
26+
code: "a {}",
27+
config: myConfig,
28+
abortSignal: controller.signal
29+
});
30+
31+
// Cancel the lint if needed.
32+
controller.abort();
33+
```
34+
35+
> [!WARNING]
36+
> Aborting a lint operation that has [`fix`](options.md#fix) enabled with [`files`](node-api.md#files) may leave a workspace in a partially-fixed state, as some files may have already been written before cancellation.
37+
1338
### `config`
1439

1540
A [configuration object](./configure.md).
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { readFile, rm, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import process from 'node:process';
4+
5+
import replaceBackslashes from '../testUtils/replaceBackslashes.mjs';
6+
import safeChdir from '../testUtils/safeChdir.mjs';
7+
import standalone from '../standalone.mjs';
8+
import uniqueId from '../testUtils/uniqueId.mjs';
9+
10+
const fixturesPath = replaceBackslashes(new URL('./fixtures', import.meta.url));
11+
12+
describe('standalone with abortSignal', () => {
13+
describe('using code', () => {
14+
it('completes normally when signal is not aborted', async () => {
15+
const controller = new AbortController();
16+
17+
const { results } = await standalone({
18+
code: 'a {}',
19+
config: { rules: { 'block-no-empty': true } },
20+
abortSignal: controller.signal,
21+
});
22+
23+
expect(results).toHaveLength(1);
24+
expect(results[0].warnings).toHaveLength(1);
25+
expect(results[0].warnings[0].rule).toBe('block-no-empty');
26+
});
27+
28+
it('rejects when signal is already aborted', async () => {
29+
const controller = new AbortController();
30+
31+
controller.abort();
32+
33+
await expect(
34+
standalone({
35+
code: 'a {}',
36+
config: { rules: { 'block-no-empty': true } },
37+
abortSignal: controller.signal,
38+
}),
39+
).rejects.toThrow('This operation was aborted');
40+
});
41+
42+
it('rejects with a custom abort reason', async () => {
43+
const controller = new AbortController();
44+
45+
controller.abort(new Error('Lint cancelled'));
46+
47+
await expect(
48+
standalone({
49+
code: 'a {}',
50+
config: { rules: { 'block-no-empty': true } },
51+
abortSignal: controller.signal,
52+
}),
53+
).rejects.toThrow('Lint cancelled');
54+
});
55+
});
56+
57+
describe('using files', () => {
58+
it('completes normally when signal is not aborted', async () => {
59+
const controller = new AbortController();
60+
61+
const { results } = await standalone({
62+
files: `${fixturesPath}/empty-block.css`,
63+
config: { rules: { 'block-no-empty': true } },
64+
abortSignal: controller.signal,
65+
});
66+
67+
expect(results).toHaveLength(1);
68+
expect(results[0].warnings).toHaveLength(1);
69+
expect(results[0].warnings[0].rule).toBe('block-no-empty');
70+
});
71+
72+
it('rejects when signal is already aborted', async () => {
73+
const controller = new AbortController();
74+
75+
controller.abort();
76+
77+
await expect(
78+
standalone({
79+
files: `${fixturesPath}/empty-block.css`,
80+
config: { rules: { 'block-no-empty': true } },
81+
abortSignal: controller.signal,
82+
}),
83+
).rejects.toThrow('This operation was aborted');
84+
});
85+
86+
it('rejects with a custom abort reason', async () => {
87+
const controller = new AbortController();
88+
89+
controller.abort(new Error('Lint cancelled'));
90+
91+
await expect(
92+
standalone({
93+
files: `${fixturesPath}/empty-block.css`,
94+
config: { rules: { 'block-no-empty': true } },
95+
abortSignal: controller.signal,
96+
}),
97+
).rejects.toThrow('Lint cancelled');
98+
});
99+
});
100+
101+
describe('with fix and code', () => {
102+
it('completes normally when signal is not aborted', async () => {
103+
const controller = new AbortController();
104+
105+
const { code } = await standalone({
106+
code: 'a { color: #ffffff; }',
107+
config: { rules: { 'color-hex-length': 'short' } },
108+
fix: true,
109+
abortSignal: controller.signal,
110+
});
111+
112+
expect(code).toBeDefined();
113+
});
114+
115+
it('rejects when signal is already aborted', async () => {
116+
const controller = new AbortController();
117+
118+
controller.abort();
119+
120+
await expect(
121+
standalone({
122+
code: 'a { color: #ffffff; }',
123+
config: { rules: { 'color-hex-length': 'short' } },
124+
fix: true,
125+
abortSignal: controller.signal,
126+
}),
127+
).rejects.toThrow('This operation was aborted');
128+
});
129+
});
130+
131+
describe('with fix and files', () => {
132+
safeChdir(new URL(`./tmp/standalone-abort-signal-${uniqueId()}`, import.meta.url));
133+
134+
let tempFile;
135+
136+
beforeEach(async () => {
137+
tempFile = replaceBackslashes(path.join(process.cwd(), 'stylesheet.css'));
138+
await writeFile(tempFile, 'a { color: #ffffff; }');
139+
});
140+
141+
afterEach(async () => {
142+
await rm(tempFile, { force: true });
143+
});
144+
145+
it('rejects when signal is already aborted', async () => {
146+
const controller = new AbortController();
147+
148+
controller.abort();
149+
150+
const originalContent = await readFile(tempFile, 'utf8');
151+
152+
await expect(
153+
standalone({
154+
files: tempFile,
155+
config: { rules: { 'color-hex-length': 'short' } },
156+
fix: true,
157+
abortSignal: controller.signal,
158+
}),
159+
).rejects.toThrow('This operation was aborted');
160+
161+
const currentContent = await readFile(tempFile, 'utf8');
162+
163+
expect(currentContent).toBe(originalContent);
164+
});
165+
});
166+
167+
it('rejects when signal is aborted while processing rules', async () => {
168+
const controller = new AbortController();
169+
170+
const aborter = {
171+
ruleName: 'test/aborter',
172+
rule: () => async () => {
173+
controller.abort(new Error('mid-run'));
174+
},
175+
};
176+
177+
await expect(
178+
standalone({
179+
code: 'a {}',
180+
config: { plugins: [aborter], rules: { 'test/aborter': true } },
181+
abortSignal: controller.signal,
182+
}),
183+
).rejects.toThrow('mid-run');
184+
});
185+
});

lib/lintPostcssResult.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import timing from './timing.mjs';
1818
* @returns {Promise<any>}
1919
*/
2020
export default async function lintPostcssResult(stylelintOptions, postcssResult, config) {
21+
const { abortSignal } = stylelintOptions;
22+
2123
postcssResult.stylelint.stylelintError = false;
2224
postcssResult.stylelint.stylelintWarning = false;
2325
postcssResult.stylelint.quiet = config.quiet;
@@ -136,7 +138,15 @@ export default async function lintPostcssResult(stylelintOptions, postcssResult,
136138
return ruleFn(postcssRoot, postcssResult);
137139
}
138140

139-
performRules.push(Promise.all(postcssRoots.map(runRule)));
141+
performRules.push(
142+
Promise.all(postcssRoots.map(runRule)).then((result) => {
143+
if (abortSignal?.aborted) {
144+
throw abortSignal.reason;
145+
}
146+
147+
return result;
148+
}),
149+
);
140150
}
141151

142152
return Promise.all(performRules);

lib/lintSource.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export default async function lintSource(stylelint, options = {}) {
2929
return Promise.reject(new Error('You must provide filePath, code, or existingPostcssResult'));
3030
}
3131

32+
const { abortSignal } = options;
33+
34+
if (abortSignal?.aborted) {
35+
throw abortSignal.reason;
36+
}
37+
3238
const isCodeNotFile = options.code !== undefined;
3339

3440
const inputFilePath = isCodeNotFile ? options.codeFilename : options.filePath;
@@ -47,6 +53,10 @@ export default async function lintSource(stylelint, options = {}) {
4753
throw err;
4854
});
4955

56+
if (abortSignal?.aborted) {
57+
throw abortSignal.reason;
58+
}
59+
5060
if (isIgnored) {
5161
return createEmptyPostcssResult(inputFilePath, options.existingPostcssResult);
5262
}
@@ -57,6 +67,10 @@ export default async function lintSource(stylelint, options = {}) {
5767
filePath: inputFilePath,
5868
});
5969

70+
if (abortSignal?.aborted) {
71+
throw abortSignal.reason;
72+
}
73+
6074
if (!configForFile) {
6175
return Promise.reject(new Error('Config file not found'));
6276
}
@@ -81,8 +95,16 @@ export default async function lintSource(stylelint, options = {}) {
8195
customSyntax: config._resolvedCustomSyntax,
8296
}));
8397

98+
if (abortSignal?.aborted) {
99+
throw abortSignal.reason;
100+
}
101+
84102
const referenceRoots = await getReferenceRoots(config);
85103

104+
if (abortSignal?.aborted) {
105+
throw abortSignal.reason;
106+
}
107+
86108
const stylelintPostcssResult = Object.assign(postcssResult, {
87109
stylelint: {
88110
ruleSeverities: {},
@@ -99,6 +121,10 @@ export default async function lintSource(stylelint, options = {}) {
99121

100122
await lintPostcssResult(stylelint._options, stylelintPostcssResult, config);
101123

124+
if (abortSignal?.aborted) {
125+
throw abortSignal.reason;
126+
}
127+
102128
reportDisables(stylelintPostcssResult);
103129
needlessDisables(stylelintPostcssResult);
104130
invalidScopeDisables(stylelintPostcssResult);

lib/standalone.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const ALWAYS_IGNORED_GLOBS = ['**/node_modules/**'];
4141
* @type {import('stylelint').PublicApi['lint']}
4242
*/
4343
export default async function standalone({
44+
abortSignal,
4445
allowEmptyInput,
4546
cache,
4647
cacheLocation,
@@ -155,11 +156,20 @@ export default async function standalone({
155156
let stylelintResult;
156157

157158
try {
159+
if (abortSignal?.aborted) {
160+
throw abortSignal.reason;
161+
}
162+
158163
const postcssResult = await lintSource(stylelint, {
159164
code,
160165
codeFilename: absoluteCodeFilename,
166+
abortSignal,
161167
});
162168

169+
if (abortSignal?.aborted) {
170+
throw abortSignal.reason;
171+
}
172+
163173
stylelintResult = createPartialStylelintResult(postcssResult);
164174
} catch (error) {
165175
stylelintResult = handleError(error);
@@ -244,11 +254,20 @@ export default async function standalone({
244254
debug(`Processing ${absoluteFilepath}`);
245255

246256
try {
257+
if (abortSignal?.aborted) {
258+
throw abortSignal.reason;
259+
}
260+
247261
const postcssResult = await lintSource(stylelint, {
248262
filePath: absoluteFilepath,
249263
cache: useCache,
264+
abortSignal,
250265
});
251266

267+
if (abortSignal?.aborted) {
268+
throw abortSignal.reason;
269+
}
270+
252271
if (
253272
(postcssResult.stylelint.stylelintError || postcssResult.stylelint.stylelintWarning) &&
254273
useCache

types/stylelint/index.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,12 +1140,19 @@ declare namespace stylelint {
11401140
export type GetLintSourceOptions = GetPostcssOptions & {
11411141
existingPostcssResult?: PostCSS.Result;
11421142
cache?: boolean;
1143+
abortSignal?: AbortSignal;
11431144
};
11441145

11451146
/**
11461147
* Linter options.
11471148
*/
11481149
export type LinterOptions = {
1150+
/**
1151+
* An `AbortSignal` to cancel the linting process. If the signal is
1152+
* aborted, the linting process will be stopped and the signal's
1153+
* `reason` will be thrown as an error.
1154+
*/
1155+
abortSignal?: AbortSignal;
11491156
files?: OneOrMany<string>;
11501157
globbyOptions?: GlobbyOptions;
11511158
cache?: boolean;

0 commit comments

Comments
 (0)