Skip to content

Releases: ursm/xpathway

v1.0.4

Choose a tag to compare

@github-actions github-actions released this 02 Jun 02:36
68f6ae3

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)//E desugars to /descendant-or-self::node()/child::E (XPath 1.0 REC §2.5), whose node-set is exactly descendant::E. A new parse-time normalization fuses that step pair into a single descendant::E step, so the common .//X shape now streams one tree walk instead of materializing the entire descendant-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, or position()/last()), so //e[1], //e[last()], etc. keep their exact per-parent semantics.
  • Fewer allocations in the predicate loopapplyPredicates reuses one child context per frame and skips the output-array copy when a predicate keeps every candidate; unionNodeSets short-circuits an empty side; NodeSet.first fast-paths the common single-node set.
  • Less per-node work — single-pass normalize-space over the exact XPath whitespace set (#x20|#x9|#xD|#xA), with a fast path for already-normalized text; one Map lookup on the string-value memo hit path; * name tests skip the name/namespace adapter reads; structural classifications (single relative step, @name <op> literal shape, 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

Choose a tag to compare

@github-actions github-actions released this 01 Jun 14:39
f4017ad

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
    megamorphic AXES[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 child and attribute axes no longer
    .slice() the adapter's arrays. evaluateStep treats 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% of slice
    (CloneFastJSArray) overhead; a regression test guards the invariant.
  • Fused descendant walk — a selective //name applies 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

Choose a tag to compare

@github-actions github-actions released this 01 Jun 10:58
396bcc6

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::select are
    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),
    so getAttribute(el, null, 'type') finds an attribute written TYPE.

Full Changelog: v1.0.1...v1.0.2

v1.0.1

Choose a tag to compare

@github-actions github-actions released this 01 Jun 09:55
9aa7ad7

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. A self::a | self::b | self::c predicate 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-node and
    actions/checkout to v6.

Full Changelog: v1.0.0...v1.0.1

v1.0.0

Choose a tag to compare

@ursm ursm released this 01 Jun 09:23
2589e81

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 match localName ASCII 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[…]/@for runs 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; no Date.now() /
    Math.random() on the evaluation path.

Testing

  • A staged conformance suite (test/conformance/): an XPath 1.0 corpus plus the
    real shapes Capybara's xpath gem emits, and a vendored web-platform-tests
    XPath case via a zero-dependency testharness shim. CI runs on Node 20/22/24.

MIT licensed.