Skip to content

Commit 0ee615f

Browse files
authored
fix: move css cache onto container to allow parallel runs (#252)
1 parent a1902e2 commit 0ee615f

3 files changed

Lines changed: 110 additions & 17 deletions

File tree

packages/beasties/src/dom.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,9 @@ import { DomUtils, parseDocument } from 'htmlparser2'
2626

2727
type ParsedDocument = ReturnType<typeof parseDocument>
2828

29-
let classCache: null | Set<string> = null
30-
let idCache: null | Set<string> = null
31-
32-
function buildCache(container: Node | HTMLDocument) {
33-
classCache = new Set()
34-
idCache = new Set()
29+
function buildCache(container: Node) {
30+
container._classCache = new Set()
31+
container._idCache = new Set()
3532
const queue = [container]
3633

3734
while (queue.length) {
@@ -40,17 +37,17 @@ function buildCache(container: Node | HTMLDocument) {
4037
if (node.hasAttribute?.('class')) {
4138
const classList = node.getAttribute('class').trim().split(' ')
4239
classList.forEach((cls) => {
43-
classCache!.add(cls)
40+
container._classCache!.add(cls)
4441
})
4542
}
4643

4744
if (node.hasAttribute?.('id')) {
4845
const id = node.getAttribute('id').trim()
49-
idCache.add(id)
46+
container._idCache!.add(id)
5047
}
5148

5249
if ('children' in node) {
53-
queue.push(...node.children.filter(child => child.type === 'tag'))
50+
queue.push(...(node as NodeWithChildren).children.filter(child => child.type === 'tag'))
5451
}
5552
}
5653
}
@@ -112,6 +109,8 @@ declare module 'domhandler' {
112109
$$name?: string
113110
$$reduce?: boolean
114111
$$links?: ChildNode[]
112+
_classCache?: Set<string>
113+
_idCache?: Set<string>
115114
}
116115
}
117116

@@ -346,23 +345,23 @@ function extendDocument(document: ParsedDocument): asserts document is HTMLDocum
346345
// so that it's disposed with it.
347346
const selectorTokensCache = new Map<string, null | AttributeSelector[]>()
348347

349-
function cachedQuerySelector(sel: string, node: Node | Node[]) {
348+
function cachedQuerySelector(sel: string, node: Node) {
350349
let selectorTokens = selectorTokensCache.get(sel)
351350
if (selectorTokens === undefined) {
352351
selectorTokens = parseRelevantSelectors(sel)
353352
selectorTokensCache.set(sel, selectorTokens)
354353
}
355354

356-
if (selectorTokens) {
355+
if (selectorTokens && node._classCache && node._idCache) {
357356
for (const token of selectorTokens) {
358-
// Check if the selector is a class selector
359-
if (token.name === 'class') {
360-
return classCache!.has(token.value)
357+
if (token.name === 'class' && !node._classCache.has(token.value)) {
358+
return false
361359
}
362-
if (token.name === 'id') {
363-
return idCache!.has(token.value)
360+
if (token.name === 'id' && !node._idCache.has(token.value)) {
361+
return false
364362
}
365363
}
364+
return true
366365
}
367366

368367
return !!selectOne(sel, node)
@@ -375,7 +374,7 @@ function parseRelevantSelectors(sel: string): AttributeSelector[] | null {
375374
for (let i = 0; i < tokens.length; i++) {
376375
const tokenGroup = tokens[i]
377376
if (tokenGroup?.length !== 1) {
378-
continue
377+
return null
379378
}
380379

381380
const token = tokenGroup[0]

packages/beasties/test/beasties.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,4 +1062,28 @@ describe('beasties', () => {
10621062
// Clean up temporary directory
10631063
fs.rmSync(tmpDir, { recursive: true })
10641064
})
1065+
1066+
it('should not share class/id caches between concurrent process() calls', async () => {
1067+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'beasties-concurrent-'))
1068+
fs.mkdirSync(path.join(tmpDir, 'static'), { recursive: true })
1069+
fs.writeFileSync(path.join(tmpDir, 'static', 'style.css'), '.foo{color:red}#bar{color:blue}#missing{color:green}')
1070+
1071+
const html1 = '<html><head><link rel="stylesheet" href="/static/style.css"></head><body><div class="foo" id="bar">hello</div></body></html>'
1072+
const html2 = '<html><head></head><body><p>no css</p></body></html>'
1073+
1074+
const b1 = new Beasties({ path: tmpDir, logLevel: 'silent' })
1075+
const b2 = new Beasties({ path: tmpDir, logLevel: 'silent' })
1076+
1077+
const [result1] = await Promise.all([
1078+
b1.process(html1),
1079+
b2.process(html2),
1080+
])
1081+
1082+
expect(result1).toContain('<style>')
1083+
expect(result1).toContain('.foo{color:red}')
1084+
expect(result1).toContain('#bar{color:blue}')
1085+
expect(result1).not.toContain('#missing{color:green}')
1086+
1087+
fs.rmSync(tmpDir, { recursive: true })
1088+
})
10651089
})

packages/beasties/test/dom.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createDocument } from '../src/dom'
3+
4+
describe('dom', () => {
5+
describe('exists() selector cache', () => {
6+
it('falls through to DOM query when selector has a complex group', () => {
7+
const doc = createDocument(`
8+
<html>
9+
<body>
10+
<div class="parent"><span class="child">text</span></div>
11+
</body>
12+
</html>
13+
`)
14+
const container = doc.beastiesContainer
15+
16+
/*
17+
".parent .child" (descendant combinator) can't be resolved by the
18+
selector cache, so exists() must fall through to a full DOM query.
19+
The comma means OR, so the result is true because ".parent .child" matches.
20+
*/
21+
expect(container.exists('.nonexistent, .parent .child')).toBe(true)
22+
})
23+
24+
it('gives consistent results regardless of selector order', () => {
25+
const doc = createDocument(`
26+
<html>
27+
<body>
28+
<div class="present">text</div>
29+
</body>
30+
</html>
31+
`)
32+
const container = doc.beastiesContainer
33+
34+
/*
35+
CSS comma means OR — both selectors are logically equivalent,
36+
so exists() must return the same result regardless of order.
37+
*/
38+
expect(container.exists('.present, .absent'))
39+
.toBe(container.exists('.absent, .present'))
40+
})
41+
42+
it('returns true for simple class selector that exists', () => {
43+
const doc = createDocument(`
44+
<html><body><div class="hero">text</div></body></html>
45+
`)
46+
expect(doc.beastiesContainer.exists('.hero')).toBe(true)
47+
})
48+
49+
it('returns false for simple class selector that does not exist', () => {
50+
const doc = createDocument(`
51+
<html><body><div class="hero">text</div></body></html>
52+
`)
53+
expect(doc.beastiesContainer.exists('.missing')).toBe(false)
54+
})
55+
56+
it('returns true for simple id selector that exists', () => {
57+
const doc = createDocument(`
58+
<html><body><div id="main">text</div></body></html>
59+
`)
60+
expect(doc.beastiesContainer.exists('#main')).toBe(true)
61+
})
62+
63+
it('returns false for simple id selector that does not exist', () => {
64+
const doc = createDocument(`
65+
<html><body><div id="main">text</div></body></html>
66+
`)
67+
expect(doc.beastiesContainer.exists('#nope')).toBe(false)
68+
})
69+
})
70+
})

0 commit comments

Comments
 (0)