Skip to content

Commit 8020750

Browse files
tinchox5claude
andcommitted
perf: defer buildCounterContext's document walk until actually needed
sessionCache.__counterCtx was built eagerly on the first pseudo-element check of any capture, walking the entire document even when nothing in the captured content uses CSS counters. Every real call site of the context's get()/getStack() is already gated behind an actual counter-reset/-increment or counter()/counters() usage, so wrap it in a lazy memoizing get/getStack pair instead — same full-document walk (required for correctness: counter state can depend on ancestors and preceding siblings outside the captured subtree), just deferred until first genuinely needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 56cbe26 commit 8020750

2 files changed

Lines changed: 81 additions & 3 deletions

File tree

__tests__/module.pseudo.test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,17 @@ vi.mock('../src/modules/fonts.js', async (importOriginal) => {
2020
}
2121
})
2222

23+
vi.mock('../src/modules/counter.js', async (importOriginal) => {
24+
const actual = await importOriginal()
25+
return {
26+
...actual,
27+
buildCounterContext: vi.fn(actual.buildCounterContext),
28+
}
29+
})
30+
2331
import * as helpers from '../src/utils/index.js'
2432
import * as fonts from '../src/modules/fonts.js'
33+
import { buildCounterContext } from '../src/modules/counter.js'
2534

2635
const sessionCache = {
2736
styleMap: new Map(),
@@ -384,6 +393,48 @@ describe('inlinePseudoElements', () => {
384393
expect(before.textContent).toBe('1)')
385394
})
386395

396+
// Speed punch-list: buildCounterContext walks the whole document, so it must
397+
// only pay that cost when a pseudo actually declares counter-reset/-increment
398+
// or a counter()/counters() content value — not on every pseudo-element check.
399+
it('never walks the document for counter state when no pseudo uses counters', async () => {
400+
const el = document.createElement('div')
401+
el.className = 'no-counters-el'
402+
document.body.appendChild(el)
403+
const style = document.createElement('style')
404+
style.textContent = '.no-counters-el::before { content: "plain text"; }'
405+
document.head.appendChild(style)
406+
407+
const localSessionCache = { styleMap: new Map(), styleCache: new WeakMap() }
408+
const clone = el.cloneNode(true)
409+
await inlinePseudoElements(el, clone, localSessionCache, {})
410+
411+
expect(buildCounterContext).not.toHaveBeenCalled()
412+
})
413+
414+
it('builds the document counter context lazily, once, only when a pseudo needs it', async () => {
415+
const ol = document.createElement('ol')
416+
ol.className = 'lazy-ctx-ol'
417+
const li1 = document.createElement('li')
418+
li1.className = 'lazy-ctx-li'
419+
const li2 = document.createElement('li')
420+
li2.className = 'lazy-ctx-li'
421+
ol.append(li1, li2)
422+
document.body.appendChild(ol)
423+
const style = document.createElement('style')
424+
style.textContent = `
425+
.lazy-ctx-ol { counter-reset: item; list-style: none; }
426+
.lazy-ctx-li::before { counter-increment: item; content: counter(item); }
427+
`
428+
document.head.appendChild(style)
429+
430+
const localSessionCache = { styleMap: new Map(), styleCache: new WeakMap() }
431+
const cloneOl = ol.cloneNode(true)
432+
await inlinePseudoElements(li1, cloneOl.children[0], localSessionCache, {})
433+
await inlinePseudoElements(li2, cloneOl.children[1], localSessionCache, {})
434+
435+
expect(buildCounterContext).toHaveBeenCalledTimes(1)
436+
})
437+
387438
// #19: a pseudo's own counter-set must override the counter value before resolving content.
388439
it('applies counter-set on a pseudo element', async () => {
389440
const ol = document.createElement('ol')

src/modules/pseudo.js

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,31 @@ function hasPaintedBorder(style) {
240240
}
241241
var __pseudoEpoch = -1
242242

243+
/**
244+
* Wraps buildCounterContext(doc) — an O(document) walk — behind a memoizing
245+
* get/getStack pair so the walk only runs on the first actual counter query,
246+
* not on every element's pseudo-element check.
247+
* @param {Document} doc
248+
* @param {Object} sessionCache
249+
* @returns {{get:Function, getStack:Function}}
250+
*/
251+
function lazyCounterContext(doc, sessionCache) {
252+
let built = null
253+
const ensure = () => {
254+
if (!built) {
255+
try { built = buildCounterContext(doc) } catch (e) {
256+
debugWarn(sessionCache, 'buildCounterContext failed', e)
257+
built = { get: () => 0, getStack: () => [] }
258+
}
259+
}
260+
return built
261+
}
262+
return {
263+
get(node, name) { return ensure().get(node, name) },
264+
getStack(node, name) { return ensure().getStack(node, name) }
265+
}
266+
}
267+
243268
/**
244269
* Concatena tokens de CSS `content` (cadenas y resultados de counter()/counters())
245270
* sin el whitespace que los separa en el source — el browser concatena tokens
@@ -425,10 +450,12 @@ export async function inlinePseudoElements(source, clone, sessionCache, options)
425450
__pseudoEpoch = epoch
426451
}
427452

453+
// buildCounterContext walks the whole document once — defer it behind a lazy
454+
// wrapper so that cost is only paid the first time a pseudo actually declares
455+
// counter-reset/-increment or a counter()/counters() content value (every real
456+
// call site below is already gated that way), not on every element's pseudo check.
428457
if (!sessionCache.__counterCtx) {
429-
try { sessionCache.__counterCtx = buildCounterContext(source.ownerDocument || document) } catch (e) {
430-
debugWarn(sessionCache, 'buildCounterContext failed', e)
431-
}
458+
sessionCache.__counterCtx = lazyCounterContext(source.ownerDocument || document, sessionCache)
432459
}
433460
const counterCtx = sessionCache.__counterCtx
434461

0 commit comments

Comments
 (0)