Releases: ursm/xpathway
Release list
v1.0.4
A performance release. No API changes; results are identical to 1.0.3 — verified by the full conformance suite and a 500k-input differential check on normalize-space.
Performance
The headline is an algorithmic rewrite of the query shape Capybara leans on, plus a round of allocation and per-node cuts. Found with a CPU profile (node --cpu-prof bench/bench.js).
//-fusion (the big one) —//Edesugars to/descendant-or-self::node()/child::E(XPath 1.0 REC §2.5), whose node-set is exactlydescendant::E. A new parse-time normalization fuses that step pair into a singledescendant::Estep, so the common.//Xshape now streams one tree walk instead of materializing the entiredescendant-or-self::node()set (text and comment nodes included) and then re-walking every node's children. The fusion is gated by a conservative position-stability guard: it never fires when a predicate can observe proximity position or size (a numeric/positional predicate, orposition()/last()), so//e[1],//e[last()], etc. keep their exact per-parent semantics.- Fewer allocations in the predicate loop —
applyPredicatesreuses one child context per frame and skips the output-array copy when a predicate keeps every candidate;unionNodeSetsshort-circuits an empty side;NodeSet.firstfast-paths the common single-node set. - Less per-node work — single-pass
normalize-spaceover the exact XPath whitespace set (#x20|#x9|#xD|#xA), with a fast path for already-normalized text; one Map lookup on thestring-valuememo hit path;*name tests skip the name/namespace adapter reads; structural classifications (single relative step,@name <op> literalshape, folded HTML attribute name) are memoized in WeakMaps keyed by the AST node, leaving the parse tree immutable.
Total profiled CPU on the benchmark dropped ~40% (1370ms → 827ms). Per query, on the capybara-simulated–shaped benchmark (node bench/bench.js, 200-block page), ~28–58% faster, with scaling still linear:
| query | 1.0.3 | 1.0.4 |
|---|---|---|
| count all elements | 0.081ms | 0.034ms |
| all fields (union-in-predicate) | 0.214ms | 0.134ms |
field by label (//label hoist) |
0.403ms | 0.241ms |
| deep descendant attr | 0.147ms | 0.078ms |
| link by text | 0.161ms | 0.076ms |
| table cell by text | 0.366ms | 0.265ms |
Full Changelog: v1.0.3...v1.0.4
v1.0.3
A performance release. No API changes; results are identical to 1.0.2.
Performance
Profile-driven tuning of the core step evaluator (evaluateStep). Earlier
guesses aimed at cutting object/GC allocation, but on this engine GC is only
~3.5% of runtime — so a CPU profile (node --prof bench/bench.js) was used to
find the real hot spots:
- Megamorphic axis lookup — the axis function is now resolved once per
step (resolveAxis) instead of once per input node, eliminating a
megamorphicAXES[axis]keyed-load IC (~6.5% → ~1.6% of ticks). - Redundant predicate classification — a predicate's "is it a pure node-set
(existence filter) vs a position test" is a static property of the AST, now
computed once per step instead of once per input node (isPureNodeSet
~3.3% → ~0.1%); predicate-less steps skip the predicate pass entirely. - Defensive array copies — the
childandattributeaxes no longer
.slice()the adapter's arrays.evaluateSteptreats axis results as
read-only (it copies matches into a fresh array), so an axis array is never
mutated or used as a node-set's backing store. This removes ~5% ofslice
(CloneFastJSArray) overhead; a regression test guards the invariant. - Fused descendant walk — a selective
//nameapplies its node test during
the descendant traversal instead of materializing the whole descendant set
and then filtering it. - Empty input node-sets short-circuit before any per-step setup.
On the capybara-simulated–shaped benchmark this is ~10–29% faster across the
query mix, with scaling still linear:
| query | 1.0.2 | 1.0.3 |
|---|---|---|
| count all elements | 0.160ms | 0.114ms |
| all fields (union-in-predicate) | 0.396ms | 0.306ms |
field by label (//label hoist) |
0.743ms | 0.597ms |
| deep descendant attr | 0.261ms | 0.215ms |
| link by text | 0.281ms | 0.231ms |
| table cell by text | 0.570ms | 0.512ms |
Full Changelog: v1.0.2...v1.0.3
v1.0.2
A performance release. No API changes; results are identical to 1.0.1.
Performance
Attribute predicates — the dominant shape Capybara's selector builder emits
(./@id, ./@type, ./@name, ./@href, …) — no longer materialize an
attribute-axis node-set per candidate node. @name <op> literal and @name
existence are now resolved with a single getAttribute call, and the ./@name
form (a leading no-op self::node() step) takes the same fast path as the bare
@name.
On the capybara-simulated field selector (300-block page), this collapses the
attribute predicates:
| predicate | 1.0.1 | 1.0.2 |
|---|---|---|
[not(@type…)] |
2321µs | 454µs |
[@id/@name…] |
4199µs | 472µs |
| field selector (whole) | 5513µs | 1685µs |
Also in this release:
- node-set predicates such as
self::input | self::textarea | self::selectare
evaluated by existence (short-circuit), without materializing a node-set; - the HTML-document flag is computed once per evaluation and cached on the
context.
vs. wgxpath
Measured against the engine xpathway replaces, on the same page with matching
result counts: the field selector is ~54.7× faster (1685µs vs 92178µs) and
the link selector ~2.08× faster. wgxpath re-scans //label[…]/@for once per
candidate input; xpathway hoists it once per evaluation.
Correctness / docs
- Documented the adapter invariant the attribute fast path relies on: an adapter
reporting an HTML document must key attributes case-insensitively (lower case),
sogetAttribute(el, null, 'type')finds an attribute writtenTYPE.
Full Changelog: v1.0.1...v1.0.2
v1.0.1
A performance + robustness release. No public API changes.
Performance
- Node-set predicates (a location path, filter, or union — e.g.
self::input | self::textarea | self::select) are now evaluated by
existence: their truth is decided by short-circuit without materializing a
node-set. Aself::a | self::b | self::cpredicate collapses to an OR of
per-arm name tests on the context node. The union-in-predicate query is ~2×
faster and the field-by-label query ~1.5×; scaling stays linear.
Correctness
- A malformed union predicate with a non-node-set arm (e.g.
a | count(b))
again raises the XPath type error it should, instead of being silently coerced
to a boolean by the existence optimization.
Maintenance
- Add Dependabot for the GitHub Actions workflows; bump
actions/setup-nodeand
actions/checkoutto v6.
Full Changelog: v1.0.0...v1.0.1
v1.0.0
First release of xpathway — a clean, standalone, XPath 1.0-compliant
evaluation library in zero-dependency ESM JavaScript, driving the browser's DOM
Level 3 XPath API on top of an injected DOM adapter (no Node implementation is
imported). Built to replace the unmaintained wgxpath blob in capybara-simulated
while staying reusable on any tree.
Features
- Full XPath 1.0: all 13 axes, every node test, predicates, the complete
operator set and type system (node-set / boolean / number / string with the
spec coercion rules), and the entire core function library. - Browser-compatible API:
createEvaluator(adapter)returns
evaluate/createExpression/createNSResolver, with results as an
XPathResult(full type constants and accessors). Grammar errors surface as
SyntaxError; host exception constructors are injectable. - HTML semantics (§6): in HTML documents, unprefixed element/attribute name
tests matchlocalNameASCII case-insensitively (scoped to the XHTML
namespace for elements); XML/XHTML and prefixed tests stay case-sensitive. - Performance: a parse cache, absolute-path hoisting memoized per evaluation
(//label[…]/@forruns once, not once per candidate), string-value
memoization, and iterative (stack-safe) axes. - Distribution: pure ESM, zero runtime dependencies, no Node built-ins —
bundleable with esbuild and runnable in a bare V8; noDate.now()/
Math.random()on the evaluation path.
Testing
- A staged conformance suite (
test/conformance/): an XPath 1.0 corpus plus the
real shapes Capybara'sxpathgem emits, and a vendored web-platform-tests
XPath case via a zero-dependency testharness shim. CI runs on Node 20/22/24.
MIT licensed.