Skip to content

Cache the query plan, not the answer - #188

Open
jdalton wants to merge 1 commit into
dperini:masterfrom
jdalton:perf/plan-cache
Open

Cache the query plan, not the answer#188
jdalton wants to merge 1 commit into
dperini:masterfrom
jdalton:perf/plan-cache

Conversation

@jdalton

@jdalton jdalton commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

select() caches the whole return of collect(), which carries the matched elements and closures over the context, so a removed subtree stays alive as long as its selector stays cached — confirmed with WeakRef, not heap arithmetic. Caching the context-free plan instead drops retained-after-removal from 11 mb to 1 mb and lets a plan be reused across contexts.

Detail, and how it was checked

select() caches the whole return of collect(), which carries 'results' — the matched elements — and 'htmlset', closures over the context. A removed subtree therefore stays alive for as long as its selector stays in the cache, which in a jsdom test suite is the life of the document.

Confirmed with WeakRef rather than heap arithmetic: a detached subtree survives a forced GC after one select() and does not survive without it. Measured with a heap benchmark, retained-after-removal falls from 11.12mb to 1.32mb.

What is cached now is the plan alone — compiled resolvers plus optimizer tokens, all context-free — and the candidate list is rebuilt from the context on each call. Being context-free, a plan is also reused across contexts rather than only for the one it was built against, where before a second context missed the cache entirely and rebuilt the plan.

'nodeset' now records the unescaped identifier the first run selects on. It recorded the escaped form while the first run selected on the unescaped one, so a rebuilt candidate list could ask the document for a different name.

first() also stops allocating a fresh callback closure per call for the common no-callback case: the cached plan is only reused when the callback matches, and a new closure never does, so every querySelector() rebuilt the plan it had just cached. That is 1.09-1.12x on 'div.example'.

Extracted from #167 as a standalone change: one file, applies to master on its own, and checked against the benchmark fixture to confirm every selector still agrees with the native engine.

References: the spec, the browser source, and what each part was reasoned from

This patch applies to master on its own. The sixteen in this series were checked by cherry-picking them onto master one after another, in this order and in reverse, and all sixteen land without a conflict.

select() caches the whole return of collect(), which carries 'results' — the matched elements — and 'htmlset', closures over the context. A removed subtree therefore stays alive for as long as its selector stays in the cache, which in a jsdom test suite is the life of the document.
 Confirmed with WeakRef rather than heap arithmetic: a detached subtree survives a forced GC after one select() and does not survive without it. Measured with a heap benchmark, retained-after-removal falls from 11.12mb to 1.32mb.
 What is cached now is the plan alone — compiled resolvers plus optimizer tokens, all context-free — and the candidate list is rebuilt from the context on each call. Being context-free, a plan is also reused across contexts rather than only for the one it was built against, where before a second context missed the cache entirely and rebuilt the plan.
 'nodeset' now records the unescaped identifier the first run selects on. It recorded the escaped form while the first run selected on the unescaped one, so a rebuilt candidate list could ask the document for a different name.
 first() also stops allocating a fresh callback closure per call for the common no-callback case: the cached plan is only reused when the callback matches, and a new closure never does, so every querySelector() rebuilt the plan it had just cached. That is 1.09-1.12x on 'div.example'.

References:

- Spec: https://dom.spec.whatwg.org/#dom-parentnode-queryselectorall — querySelectorAll answers with a static list, so a cached answer would be wrong
- MDN: https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant