Skip to content

Commit 3993e56

Browse files
committed
AG-50945 Replace Promise.resolve with setTimeout-based macrotask yielding in async engine creation
Squashed commit of the following: commit a31a937 Merge: 9a82713 ee9ee73 Author: scripthunter7 <d.tota@adguard.com> Date: Fri Feb 13 19:09:00 2026 +0100 Merge branch 'master' into fix/AG-50945 commit 9a82713 Author: scripthunter7 <d.tota@adguard.com> Date: Fri Feb 13 18:15:52 2026 +0100 Fix changelog commit 87c1139 Author: scripthunter7 <d.tota@adguard.com> Date: Fri Feb 13 10:36:55 2026 +0100 Fixed `Engine.createAsync` using macrotask yielding instead of microtask for proper UI thread yielding between rule-loading chunks commit 4139237 Author: scripthunter7 <d.tota@adguard.com> Date: Fri Feb 13 10:36:05 2026 +0100 Add explanatory comments for setTimeout-based macrotask yielding in engine rule loading Add inline comments explaining why setTimeout with macrotask is used instead of Promise.resolve() with microtask for yielding during async rule loading in cosmetic-engine, network-engine, and main engine. Comments clarify that macrotasks allow the browser to handle pending UI updates and user interactions, while microtasks do not. commit 06b39d1 Author: scripthunter7 <d.tota@adguard.com> Date: Thu Feb 12 17:18:39 2026 +0100 Replace Promise.resolve() with setTimeout-based macrotask yielding in async engine creation Replace `Promise.resolve()` with `setTimeout(resolve, 0)` in cosmetic-engine, network-engine, and main engine to properly yield to the event loop via macrotasks during async rule loading. Add tests to verify macrotask yielding behavior when rule count exceeds CHUNK_SIZE and confirm no yielding occurs below threshold.
1 parent ee9ee73 commit 3993e56

File tree

5 files changed

+105
-18
lines changed

5 files changed

+105
-18
lines changed

packages/tsurlfilter/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- `Engine.createAsync` using `setTimeout` (macrotask) instead of `Promise.resolve()` (microtask) for yielding to the UI thread between rule-loading chunks.
1213
- Parsing cosmetic rules with `$path` modifier.
1314

1415
## [4.0.0] - TBD

packages/tsurlfilter/src/engine/cosmetic-engine/cosmetic-engine.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,13 @@ export class CosmeticEngine {
8888
if (counter >= CHUNK_SIZE) {
8989
counter = 0;
9090

91-
// eslint-disable-next-line no-await-in-loop, no-promise-executor-return
92-
await Promise.resolve();
91+
// Pause rule loading and let the browser handle pending UI
92+
// updates (repaints, user input, etc.) before continuing.
93+
// We use setTimeout (macrotask) instead of Promise.resolve()
94+
// (microtask) because microtasks don't give the browser a
95+
// chance to refresh the screen or respond to user actions.
96+
// eslint-disable-next-line no-await-in-loop
97+
await new Promise<void>((resolve) => { setTimeout(resolve, 0); });
9398
}
9499

95100
engine.addRule(indexedRuleParts.ruleParts, indexedRuleParts.index);

packages/tsurlfilter/src/engine/engine.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,13 @@ export class Engine {
193193
if (counter >= CHUNK_SIZE) {
194194
counter = 0;
195195

196-
// eslint-disable-next-line no-await-in-loop, no-promise-executor-return
197-
await Promise.resolve();
196+
// Pause rule scanning and let the browser handle pending UI
197+
// updates (repaints, user input, etc.) before continuing.
198+
// We use setTimeout (macrotask) instead of Promise.resolve()
199+
// (microtask) because microtasks don't give the browser a
200+
// chance to refresh the screen or respond to user actions.
201+
// eslint-disable-next-line no-await-in-loop
202+
await new Promise<void>((resolve) => { setTimeout(resolve, 0); });
198203
}
199204

200205
const ruleParts = scanner.getRuleParts();

packages/tsurlfilter/src/engine/network-engine.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,13 @@ export class NetworkEngine {
8181
if (counter >= CHUNK_SIZE) {
8282
counter = 0;
8383

84-
// eslint-disable-next-line no-await-in-loop, no-promise-executor-return
85-
await Promise.resolve();
84+
// Pause rule loading and let the browser handle pending UI
85+
// updates (repaints, user input, etc.) before continuing.
86+
// We use setTimeout (macrotask) instead of Promise.resolve()
87+
// (microtask) because microtasks don't give the browser a
88+
// chance to refresh the screen or respond to user actions.
89+
// eslint-disable-next-line no-await-in-loop
90+
await new Promise<void>((resolve) => { setTimeout(resolve, 0); });
8691
}
8792

8893
engine.addRule(indexedRuleParts.ruleParts, indexedRuleParts.index);

packages/tsurlfilter/test/engine/engine.test.ts

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ describe('TestEngine - postponed load rules', () => {
160160
});
161161

162162
it('works rules are loaded async', async () => {
163-
const engine = Engine.createSync({
163+
const engine = await Engine.createAsync({
164164
filters: [
165165
{
166166
id: 1,
@@ -1418,18 +1418,89 @@ describe('Unsafe rules can be ignored', () => {
14181418
});
14191419

14201420
describe('Async engine creation', () => {
1421-
it('should create engine', async () => {
1422-
const engine = await Engine.createAsync({
1423-
filters: [
1424-
{
1425-
id: 1,
1426-
content: '||example.org^',
1427-
},
1428-
],
1421+
it('should create engine and match rules same as sync', async () => {
1422+
const rules = [
1423+
'||example.org^$third-party',
1424+
'example.org##banner',
1425+
];
1426+
const options = {
1427+
filters: [{ id: 1, content: rules.join('\n') }],
1428+
};
1429+
1430+
const syncEngine = Engine.createSync(options);
1431+
const asyncEngine = await Engine.createAsync(options);
1432+
1433+
expect(asyncEngine.getRulesCount()).toBe(syncEngine.getRulesCount());
1434+
1435+
const request = new Request('http://example.org', 'http://other.org', RequestType.Document);
1436+
const syncResult = syncEngine.matchRequest(request);
1437+
const asyncResult = asyncEngine.matchRequest(request);
1438+
1439+
expect(asyncResult.getBasicResult()).not.toBeNull();
1440+
expect(
1441+
asyncEngine.retrieveRuleText(
1442+
asyncResult.getBasicResult()!.getFilterListId(),
1443+
asyncResult.getBasicResult()!.getIndex(),
1444+
),
1445+
).toBe(
1446+
syncEngine.retrieveRuleText(
1447+
syncResult.getBasicResult()!.getFilterListId(),
1448+
syncResult.getBasicResult()!.getIndex(),
1449+
),
1450+
);
1451+
});
1452+
1453+
it('should yield to the event loop via macrotasks when rule count exceeds CHUNK_SIZE', async () => {
1454+
// Generate enough rules to trigger at least one yield (CHUNK_SIZE = 5000).
1455+
const ruleCount = 5500;
1456+
const rules: string[] = [];
1457+
for (let i = 0; i < ruleCount; i += 1) {
1458+
rules.push(`||example${i}.org^`);
1459+
}
1460+
1461+
// Schedule a macrotask that sets a flag.
1462+
// If createAsync correctly yields via setTimeout, this macrotask
1463+
// will run during engine creation, before createAsync resolves.
1464+
let macrotaskRanDuringCreation = false;
1465+
const asyncEnginePromise = Engine.createAsync({
1466+
filters: [{ id: 1, content: rules.join('\n') }],
14291467
});
1430-
const request = new Request('http://example.org', '', RequestType.Document);
1431-
const result = engine.matchRequest(request);
1432-
expect(result.getBasicResult()).not.toBeNull();
1468+
1469+
// This setTimeout callback is a macrotask. It will only execute
1470+
// between other macrotasks — i.e. only if createAsync actually
1471+
// yields to the event loop via setTimeout.
1472+
setTimeout(() => {
1473+
macrotaskRanDuringCreation = true;
1474+
}, 0);
1475+
1476+
const engine = await asyncEnginePromise;
1477+
1478+
expect(engine.getRulesCount()).toBe(ruleCount);
1479+
expect(macrotaskRanDuringCreation).toBe(true);
1480+
});
1481+
1482+
it('should not yield when rule count is below CHUNK_SIZE', async () => {
1483+
const ruleCount = 100;
1484+
const rules: string[] = [];
1485+
for (let i = 0; i < ruleCount; i += 1) {
1486+
rules.push(`||small${i}.org^`);
1487+
}
1488+
1489+
let macrotaskRanDuringCreation = false;
1490+
const asyncEnginePromise = Engine.createAsync({
1491+
filters: [{ id: 1, content: rules.join('\n') }],
1492+
});
1493+
1494+
setTimeout(() => {
1495+
macrotaskRanDuringCreation = true;
1496+
}, 0);
1497+
1498+
const engine = await asyncEnginePromise;
1499+
1500+
expect(engine.getRulesCount()).toBe(ruleCount);
1501+
// With fewer rules than CHUNK_SIZE, no setTimeout-based yield occurs
1502+
// during the scanning loop, so the macrotask should not have run yet.
1503+
expect(macrotaskRanDuringCreation).toBe(false);
14331504
});
14341505
});
14351506

0 commit comments

Comments
 (0)