Skip to content

Add Selectors 4 state pseudo-classes, upstream WPT runner, modern toolchain - #167

Open
jdalton wants to merge 46 commits into
dperini:masterfrom
jdalton:selectors4-modernization
Open

Add Selectors 4 state pseudo-classes, upstream WPT runner, modern toolchain#167
jdalton wants to merge 46 commits into
dperini:masterfrom
jdalton:selectors4-modernization

Conversation

@jdalton

@jdalton jdalton commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

This adds the Selectors 4 state pseudo-classes, a runner that checks nwsapi against the real web-platform-tests suite, and a toolchain refresh.

Four parts, in the order they matter:

Part What it does
src/nwsapi.js Wires :open, :modal, :fullscreen, :picture-in-picture, and the time-dimensional :current / :past / :future
test/upstream A Playwright runner over 41 WPT selector files, with a 375 entry expectations baseline
bench The old test/speed Benchmark.js presets ported to mitata, 9 groups and 201 selectors
toolchain pnpm 11.18.0, node >= 24, eslint 10 flat config, node build scripts, a rewritten Actions workflow

This is a large diff because the test runner and the toolchain came along with the selector work. If you would rather review the selector changes on their own, say so and I will split the runner and the toolchain into follow-ups.

The selector changes - which pseudo-classes landed, and why :closed is absent on purpose

The state pseudo-classes are wired against the elements that can actually carry the state, so :modal and :fullscreen do not match arbitrary nodes. The media-state pseudo-classes are scoped to media elements for the same reason, and isPlaying is exposed on the Snapshot so the check has something to read.

:current, :past, and :future parse as valid and never match. Selectors Level 5 defines them for time-dimensional documents, and a library with no timing model has nothing to match them against, so accepting them without matching is the behavior the spec asks for.

:closed is deliberately not implemented, which makes it a parse error. The CSSWG removed it and Chrome dropped it in 122, so treating it as valid would mean matching something no browser matches.

Two smaller fixes ride along: an isInstanceOf typo, and the README's sponsorship section, which had corrupted commit fragments in it.

How the WPT runner works - a pinned sparse checkout, and no gitlink

The suite comes from a sparse, shallow, blob:none web-platform-tests checkout pinned in .gitmodules. There is no gitlink committed. The ref is the pin of record, alongside an ls-tree manifest sha256, so the pin is reviewable as text rather than as an opaque commit id.

scripts/git-partial-submodule.mjs drives it with no dependencies and supports clone, verify, verify --deep, and restore-sparse. Its argv handling is hardened, since it shells out to git.

The runner is filterable while you work on one thing: WPT_FILTER narrows to an individual selector and WPT_SECTION narrows to a spec section. The expectations file records the current state so a change in results shows up as a diff rather than as a wall of failures.

Toolchain details - what to run, and what changed underneath

pnpm is pinned at 11.18.0 through packageManager, with engineStrict and saveExact set in pnpm-workspace.yaml so dependency versions stay exact. engines.node is >= 24.

The build scripts are plain node: terser for the minified build, and a clean script. eslint moves to 10 with a flat config. The Actions workflow is rewritten against the above.

For local serving over https, portless from vercel-labs serves the pages at https://nwsapi.localhost, which is what the browser tests point at.

@dperini
dperini self-requested a review August 28, 2026 00:59
…chain

- src/nwsapi.js: wire the time-dimensional :current/:past/:future (valid,
  never matching per Selectors Level 5); fix isInstanceOf typo, expose
  isPlaying on the Snapshot, scope media-state pseudo-classes to media
  elements without depending on a global HTMLMediaElement (absent in
  headless hosts), drop a dead `source` initializer in compile()
- upstream/wpt: sparse+shallow+blob:none web-platform-tests checkout pinned
  in .gitmodules (gitlink-less; ref is the pin of record; ls-tree manifest
  sha256) with dependency-free scripts/git-partial-submodule.mjs
  (clone / verify / verify --deep / restore-sparse, argv-hardened)
- test/upstream: Playwright runner over 41 WPT files plus state-pseudo
  browser tests (47 tests, 354-entry expectations baseline), filterable by
  individual selector (WPT_FILTER) or spec section (WPT_SECTION). The
  baseline records five subtests that regressed in 2.2.27 (the :has()
  descendant argument and the logicalsel pattern rejecting ':not(:is(svg|div))'
  and ':not([class]')
- bench: legacy test/speed Benchmark.js presets (9 groups, 201 selectors)
  ported verbatim to mitata under node + jsdom with --preset/--selector
- toolchain: pnpm 11.18.0 pinned with exact deps and node >=24
  (engineStrict/saveExact in pnpm-workspace.yaml), eslint 10 flat config,
  node build scripts (terser min, clean), rewritten GitHub Actions
  workflow, portless (vercel-labs) for https://nwsapi.localhost serving
- README: document the state and time pseudo-classes as the engine now
  implements them, plus the development workflow
Four defects, all present in plain 2.2.27 and all reproduced here before
being fixed. Three arrived with the 2.2.25 compiler rework (upstream
7a22775); the fourth arrived with the 2.2.26 state pseudo-classes.

Forgiving :is()/:where(). The fallback tested /(:(?:is|where)\\x28)/, where
the doubled escape matches a literal backslash rather than an opening
parenthesis, so ':not(:is(svg|div))' raised "unknown pseudo-class selector"
instead of matching nothing.

EOF-terminated arguments. The linguistic, logicalsel and treestruct groups
lost their '(?:\x29|$)' terminator, so ':not([class]' and
'meta[charset="utf-8"' were parse errors rather than being closed by EOF the
way the CSS Syntax parser closes any open construct
(/css/selectors/missing-right-token.html). Restoring the terminator alone
reintroduces the bug it papered over: with '[^()]*|.*' the greedy
alternative swallows the closing parenthesis of a nested argument, so
':not(:is(div))' compiles ':is(div))'. A regular expression cannot track
nesting, so the argument of :is, :where, :matches, :not and :has is now
delimited by matchLogical(), which scans for the balanced closing
parenthesis, honoring quotes and escapes, and falls back to EOF.

:has() anchoring. The relative argument was compiled by prefixing ':scope ',
then collect() was called directly. ':scope' compiles to a comparison
against Snapshot.from, which only select() keeps up to date, so inside
:has() it still pointed at the outer query context: the ancestor walk was
not bounded by the element under test and '.x:has(.d .e)' matched an .x
whose only .d was itself, while ':has(child)' on an element outside the
document matched nothing. ':scope' cannot stand in for the anchor in any
case, since an explicit ':scope' inside the argument keeps referring to the
scoping root of the outer query, which
/css/selectors/has-argument-with-explicit-scope.html asserts. The implied
anchor is now the private ':-nwsapi-anchor' pseudo-class, compiled against
Snapshot.anchor, which has() sets around the argument and restores in a
finally block. The sibling arguments take the same path with the parent as
the collection context, which retires the open-coded '+' branch and the '~'
branch that ignored its argument entirely.

Re-entry under jsdom. matchesNative() reached for node.matches at match
time. jsdom wires Element.prototype.matches back into nwsapi, so resolving
':modal' called jsdom, which called nwsapi, which resolved ':modal' again
until the stack was exhausted, and the RangeError was then swallowed and
reported as a plain false. Measured against 2.2.27, one NW.match(':modal',
element) makes 5,428,790 re-entrant calls; it is 0 after this change.
Provenance is established once, when the factory runs, from
global.Element.prototype, and node.matches is never consulted; a
re-entrancy guard remains for hosts that pass their window as the global,
where the captured matcher can itself be a delegating wrapper. That is
upstream dperini#172, dperini#171 and dperini#177, combining the approaches of
their PRs dperini#176 and dperini#170.

Attribute selector after a pseudo-class. The combinator alternative inside
the validator's pseudo-class pattern was '[>+~][^>+~]', which consumes the
character after the combinator; when that is the '[' of an attribute
selector the attribute can no longer be parsed and the whole selector is
rejected. The top-level combinator pattern already uses a lookahead, so the
two now agree. "[class*='a' i]:not(:empty) + [class*='b']" is upstream
dperini#175, which reaches jsdom users through
@testing-library/user-event. The error it raised named a selector with
commas where its quotes should be, because emit() was passed the array of
fragments the validator did match rather than the selector; it now names the
selector.

/css/selectors/has-relative-argument.html now passes in full. 16 entries
leave the WPT baseline, 336 remain, none added. A 'node' Playwright project
covers the regressions that only appear when nwsapi is the engine behind a
host's matches().
Three harnesses, all following the measurement discipline written up for
zod's compile benchmarks, because the mistakes it lists are the ones this
work was about to make.

selectors.bench.mjs gains '--compare <path>', which loads a second nwsapi
build into the same process and the same document and times it interleaved
with the working tree. Absolute timings drift by tens of percent between
runs, so a speedup only means something as a ratio of two measurements taken
microseconds apart. It takes a plain file, so the other side can be any
'git show <ref>:src/nwsapi.js'. The timed function now also reads its
selector and context out of an array rather than closing over them: passed
as constants the call is loop-invariant, and V8 hoists plain interpreted
code out of the timing loop far more readily than an opaque new Function
closure, which quietly flatters whichever side compiles less.

memory.bench.mjs reports retained heap per engine instance, per cached
selector, and after a queried subtree is removed. It allocates many copies
and divides, since one instance is all noise; it alternates engine order
across rounds and takes the median, because the engine measured second runs
in a warmer heap; and it compares subtree removal against an unqueried
control, since an earlier version reported retention that was its own local
variable holding the subtree.

cache.bench.mjs sweeps CACHE_LIMIT the way domSelector's bench-cache.js
does, materializing a variant of the engine per limit and reporting retained
heap alongside throughput, over three workloads: a working set that fits, one
that straddles the limit, and one that overruns it.

Also documents the node project in the READMEs.
Three changes to what the engine holds on to, in the spirit of the zod 4.5
memory write-up: do not retain what is not needed, and do not materialize
what is never used.

select() cached the whole return of collect(), which carries 'results' (the
matched elements) and 'htmlset' (closures over the context). A removed
subtree therefore stayed alive for as long as its selector stayed in the
cache, which in a jsdom suite is the life of the document. Confirmed with
WeakRef rather than heap arithmetic: on 2.2.27 a detached subtree survives a
forced GC after one select() and does not survive without it. 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 per call.
Retained-after-removal falls from 11.12mb to 1.32mb and the WeakRef probe
goes from alive to collected. Being context-free, a plan is also reused
across contexts rather than only for the one it was built against, and
'nodeset' now records the unescaped identifier the first run selects on, so
a rebuilt list cannot disagree with the original for an escaped identifier.

The :hover listeners were attached to every document at construction, for a
pseudo-class most callers never use, and kept the last hovered element
alive. They are installed on first compile of a ':hover' selector and follow
a document change. This one is not a measurable saving — 976.86kb against
978.76kb per instance, where the jsdom document is 977kb of that — it
removes two capture-phase listeners per document and a retained element.

CACHE_LIMIT goes from 1000 to 4096, the same value the nwsapi fork inside
jsdom's current engine uses, measured with bench/cache.bench.mjs:

  workload                     1000      2048      4096      8192
  30 selectors, all hit     26.18us   26.93us   26.80us   27.27us
  2000 selectors            50.75ms   50.36ms    4.12ms    4.15ms
  heap, cache full           2.99mb    3.86mb    6.87mb   14.05mb

A working set that fits costs the same at any limit; one that fits 4096 and
not 1000 is worth 12x; 8192 buys nothing and doubles the worst case, which
is only reached by a caller with that many distinct selectors, since the
caches grow lazily. The cliff sits between 2048 and 4096 for 2000 selectors
because a selector is not one cache entry: ':not(.x)' compiles to a run-time
s.match('.x', e), so the argument takes an entry of its own, and 100 such
selectors leave 200 entries in the match caches.

The cache itself is now a Map. Its iteration order is insertion order, so
re-inserting on use makes it LRU order and the first key is the eviction
candidate. That removes the linked list and its entry object per slot, and
the '\x01' key prefix that kept user selectors away from Object.prototype,
which cost a second copy of every selector string per cache and another on
every lookup. With match_collect returning its array of resolvers instead of
an object wrapping it, a cached selector falls from 2431 bytes to 2241, of
which 1295 are the compiled Function itself.
Five changes, each measured against the build before it, in one process.

The optimizer could not read a nested functional pseudo-class. Before
testing candidates, collect() asks reOptimizer for the last simple token of
a selector and fetches the candidates by tag, class or id. The parenthesized
part of that pattern stopped at the first ')', so a final compound holding a
nested functional pseudo-class did not match at all — and a selector the
optimizer cannot read is answered by walking every element in the context.
'div:not(:nth-of-type(2n))' tested 6344 elements instead of 94 divs, and
since ':not()' evaluates its argument through s.match() per element, that
meant 6344 nth resolutions building 3911 sibling caches over 196312 steps.
It now tolerates two levels of nesting, reaching ':not(:not(:not(span)))';
both patterns stay linear on unbalanced input, 3200 unclosed parens in
0.02ms.

  div:not(:nth-of-type(2n))          45.62ms -> 134.92us    338x
  div:not(:nth-child(3))              9.42ms -> 123.94us     76x
  div:is(.example):not(:where(.x))    2.65ms ->  39.45us     67x

'#id' walked the document. nwsapi reaches for document.all to answer an id
lookup and falls back to walking the subtree when it is missing; jsdom does
not implement document.all, so every '#id' took the walk — 2.4ms on a
6300-element document against 43ns for getElementById. getElementById cannot
answer alone, since a document may carry an id more than once and
querySelectorAll matches all of them, but it settles two things in constant
time and each buys back one case: whether the id exists anywhere, so
select('#missing') returns immediately (2.19ms to 0.0007ms), and where the
first one is, which is all querySelector wants (first('#title') 3.74ms to
0.0007ms). Element-scoped queries and detached subtrees keep the old path.

The cache evicted with Map.delete. A strict LRU reorders on use and evicts
one entry per insertion, both via delete, and V8 keeps a deleted entry in
the backing store until the map rehashes — so keys().next(), the way the
oldest entry is found, walks the tombstones every earlier eviction left.
Profiling 8000 selectors through a 4096-entry cache put Map.set at 28% of
run time. Entries are now written to a young generation; when it fills it
becomes the old generation and the previous old one is dropped whole. A hit
in the old generation carries the entry back. Capacity is unchanged, half
per generation, and get() no longer calls has() first, since a value is
never undefined.

  30 selectors, all hit      4.68us ->  3.13us    1.50x
  2000 selectors             690us  ->   732us    0.94x
  3000 selectors            22.72ms ->   973us   23.34x
  8000 selectors            67.60ms -> 34.86ms    1.94x

The loss is a working set that straddles a generation. At 3000 the
comparison inverts because a ':not()' argument takes its own entry, so 3000
selectors overflow a 4096-entry LRU while the segmented cache degrades
instead of thrashing.

A constant nth index needed no index. ':nth-child(3)' compiled to
n=s.nthElement(e,false) then n==3, and nthElement numbers an element by
building the sibling list of its parent. The generated code now counts
siblings and stops as soon as the index is exceeded, walking at most b of
them and allocating nothing: 115.99us to 46.54us. Only the -child forms —
of-type has to compare the name of every sibling it steps over, and reading
localName through the host costs more than the list it avoids, measured 2.0x
and 2.6x slower, so those keep the cached list.

The QSA wrappers allocated twice per call. install() forwarded arguments as
sliceCall(arguments).concat(resolver); argsWith() sizes the list by arity in
one allocation, unrolled to eight. An installed querySelector('#root') goes
from 175ns to 95ns across three order-swapped rounds, the wrapper itself
from 98ns to 19ns. On an expensive query it disappears: ~1% of 11.7us and
not separable from noise. Array.prototype.slice is also uncurried once as
sliceCall, which is a readability and intrinsic-capture change rather than a
speed one, 96.35ns against 95.86ns.

Also corrects two regular expressions of the same shape in generated code.
/^a|area$/ alternates '^a' with 'area$' rather than anchoring an
alternation, so ':link' and ':any-link' matched any element whose name
begins with 'a', <abbr href> among them, where the native engine matches
only the <a>; it is now an isLink() helper. /^input|textarea$/ in
':placeholder-shown' had the same shape. Hoisting the pattern out of
generated code is not a speed change: three runs put the helper between
1.15x faster and 1.05x slower than the inline literal.
The WPT suite exercises what browsers agree on, so a defect that only shows
outside that agreement passes it. These are the fixes on this branch that
had no test of their own, plus the one that turned out to be wrong.

Generated code that reads correctly by accident. /^a|area$/ alternates '^a'
with 'area$' rather than anchoring an alternation, so ':link' accepted any
name starting with 'a' — and agreed with browsers on <a> and <area>, which
is why nothing caught it. /^input|textarea$/ in ':placeholder-shown' is the
same shape, masked by the conditions around it.

:hover tracking. Asserts that no document listener is installed until a
':hover' selector is compiled, that an ordinary selector does not install
them, and that ':hover' still matches once they are, driven by a dispatched
MouseEvent.

What a cached plan replays. An escaped identifier has to resolve the same
way on the call served from the cache as on the call that built it, and a
selector has to survive the cache turning over — 5000 distinct selectors
through a 4096-entry cache, where a generation is dropped whole.

Agreement with the reference engine. jsdom 30 resolves selectors with
@asamuzakjp/dom-selector, so querySelectorAll is a second implementation to
compare against, over the shapes whose candidate list the optimizer had to
be taught to read.

That comparison immediately found a defect in this branch. Restoring the
forgiving fallback stopped 'div:not(:is(svg|div))' from throwing, but it
matched every element in the document instead of the divs, where both
Chromium and the reference match the divs. When the validator cannot read a
selector holding a forgiving list, parse() was handing on the fragments the
validator did match, each compiled as a selector of its own. It now hands on
the selector, whose forgiving argument is evaluated inside a try/catch, so
the unreadable part drops out and the rest still applies.

Two divergences remain and are asserted rather than left out, so the
boundary is recorded: a namespace-qualified type selector ('*|div') is not
supported at all, and a forgiving list drops as a whole rather than per
item, so ':is(svg|p, p)' loses the readable branch with the unreadable one.
Both are older than this branch — 2.2.24 and 2.2.27 throw on each.
A candidate can only match 'div ul li a' if a div, a ul and a li all appear
somewhere above it, and that is far cheaper to answer than the match itself.
On the benchmark fixture it is also nearly decisive: of 2370 anchors, 10
survive the tag test and 10 match. 'dl dd a' rejects 96%, 'ul li a span'
rejects all of them.

The tags above an element are summarized as bits in one integer. An
element's summary is its parent's summary plus the parent's own bit, so a
chain is walked once rather than once per candidate, and consecutive
candidates — which arrive in document order and usually share a parent —
answer from a single-entry memo without touching the Map at all. Bits
collide, which only costs a candidate that would have been rejected; the
filter never decides a match, it only skips work.

The required tags are collected as the selector compiles: a compound's tag
is promoted to a requirement when a descendant or child combinator puts it
above the candidate. A sibling combinator does not promote, and does not
disqualify either, since siblings share a parent and an ancestor of a
sibling above that parent is still an ancestor. The bits come from the same
string the generated comparison uses, so the filter cannot reject anything
the full test would have accepted.

  div ul li a      2.66ms -> 1.08ms   2.46x
  dl dd a          1.68ms -> 1.07ms   1.57x
  div p a          1.91ms -> 1.17ms   1.63x
  ul li a span     1.28ms -> 382us    3.35x   (jsdom's engine: 1.24ms)
  ul li a          1.57ms -> 1.20ms   1.31x

Two gates keep it from costing anything where it cannot pay. It is emitted
only for a selection, since matching one element has no candidates to
reject; and only when the selector walks ancestors at all, because a chain
of child combinators takes one step per combinator whatever the depth —
measured 1.47x slower on 'div.example > p > a' before that gate went in, and
unchanged after.

The summaries are dropped with the call that built them, next to where the
nth caches are reset. They key on elements, so holding them longer would
keep a removed subtree alive, and an element that moved in between would
carry a summary describing where it used to be.
The filter memoizes ancestor summaries keyed by element, so it is the second
place in the engine that could hold a removed subtree alive. The existing
retention test queried 'div.host span.leaf', which needs one ancestor tag
and so never turns the filter on; 'body div span' needs two.
':is()' and ':where()' take a forgiving selector list, where an item the
engine cannot read is dropped and the items it can read still apply. Both
compiled to a single s.match() of the whole argument inside one try/catch,
so one unreadable item took the readable ones with it: 'p:is(svg|p, p)'
matched nothing where the reference engine matches the p. Each item is now
matched on its own, which also gives every item its own cache entry.

Splitting an argument on its commas needs the same care as delimiting it:
a comma inside a nested functional pseudo-class, an attribute value or a
quoted string does not separate two selectors. splitList() scans for the
top-level commas and trims each item, since the space after a comma belongs
to the list and a leading one reads as a descendant combinator.

parse() splits selector groups with the same scanner now. It used
REX.SplitGroup, whose parenthesized alternative cannot span nesting, so
':is(p:not(.b), span)' was split into ':is(p:not(.b)' and ' span)' and
rejected as invalid. That predates this branch — 2.2.24 and 2.2.27 fail the
same way, on ':not()' as well — and the pattern is now unused.

The known-gap test moves with the boundary: the forgiving-list gap it
asserted is closed, and what remains is the namespace-qualified type
selector on its own ('*|div'), which is still unsupported. The differential
comparison against the reference engine covers the split shapes.
The branch carries a toolchain, a test suite and benchmarks that upstream has
no use for, so a pull request cannot be the branch. These two scripts derive
one minimal, self-contained patch per reported defect against a checkout of
upstream master, and check each one on its own.

upstream-patches.mjs asserts every anchor it edits, so a patch cannot
silently apply to the wrong place or to nothing — which caught a real defect
in the script itself: several replacements end in a regular expression like
'^(?:a|area)$' followed by a quote, and "$'" in a string replacement is a
substitution pattern that inserts everything after the match, duplicating
half the file. Replacements are functions now.

upstream-verify.mjs loads each patched engine against a fixture and reports
what moved relative to upstream master. A patch has to fix what it claims and
change nothing else; an unclaimed difference is a failure. That is what
showed the forgiving fix needs the parse() fallback bundled with it, since
alone it stops 'div:not(:is(svg|div))' from throwing but leaves it matching
every element in the document.

Four branches, pushed to the fork, each one file and self-contained:

  fix/jsdom-reentry           dperini#172, dperini#171, dperini#177
  fix/forgiving-and-eof       the 2.2.25 parse regressions
  fix/attribute-after-pseudo  dperini#175
  fix/link-precedence         /^a|area$/ and /^input|textarea$/
Three more standalone patches against upstream master, each verified against
the benchmark fixture for identical results and no unclaimed change:

  perf/optimizer-nesting  the candidate list of a selector ending in a nested
                          functional pseudo-class, 48.63ms -> 0.19ms measured
                          on 'div:not(:nth-of-type(2n))'
  perf/id-lookup          getElementById for the two cases it can settle,
                          first('#title') 2.559ms -> 0.002ms
  perf/nth-constant       constant nth-child by counting, 0.179ms -> 0.050ms

The builder also takes --only=<kind|name> now, and its --list works without a
checkout path.
Five more standalone patches against upstream master, each checked against
the benchmark fixture for results identical to the native engine:

  perf/cache-two-generation  eviction without Map.delete
  perf/cache-limit           1000 -> 4096
  perf/wrapper-arguments     one allocation in the install() wrappers
  perf/plan-cache            cache the plan, not the results, which also
                             stops a removed subtree being retained
  perf/ancestor-filter       reject candidates before walking ancestors

The ancestor-filter anchor for the descendant combinator needed its escapes
doubled: written as '\x09' inside a template literal it becomes a tab, and
the anchor assertion caught that it matched nothing.
'div ul li a' matched right to left starts from every <a> in the context,
which on the benchmark fixture is 2370 anchors rejected one at a time to
return 10. Descending from the leftmost tag instead asks each level for the
next one, so the set shrinks before it grows: 94 -> 6 -> 21 -> 10. For this
shape the descent is the answer, not a candidate list, so no resolver runs.

Each level keeps only elements not contained by the previous one it kept,
which leaves the subtrees disjoint. The result is then free of duplicates and
in document order without sorting, and a nested match is expanded once rather
than once per enclosing ancestor.

  selector        before    after   jsdom's engine
  div ul li a     1.189ms   0.098ms   0.168ms
  div p a         1.277ms   0.139ms   0.154ms
  dl dd a         1.181ms   0.075ms   0.081ms
  div p           0.204ms   0.069ms   0.188ms
  html body div   0.059ms   0.016ms   0.026ms
  ul li a         1.309ms   1.346ms   0.938ms   (gated out)

Descending is not always cheaper. The cost is one scoped lookup per element
of every level, so a level that explodes pays more than the pass it replaced:
'ul li a' descends 160 -> 604 -> 885 and takes 670 lookups where the ordinary
path makes one pass over 2370 anchors, measured at 2.32ms against 1.31ms.
Both gates are about that and both are read before any work is done — a first
level wider than 100 elements does not descend at all, and a running budget
of 512 lookups abandons a chain whose middle turns out to explode.

The path is taken only for a selector that is plain tags separated by single
spaces, and only without a callback, since it returns the answer rather than
a candidate list for the ordinary path to filter.
--trace-deopt on a mixed workload reports "reason: out of bounds" against
Resolver, the generated function, on every call. The candidate loop was
written while((e=c[++k])), which finds the end of the list by reading one
index past it, and V8 answers an out-of-bounds load by throwing away the
optimized code. Bounding the loop with the length removes it: the same
workload reports 2 Resolver deopts before and 0 after.

The cached path also reached its candidate list through compat, which builds
one closure to capture the context and a second to defer the call, so every
query allocated two closures to reach a function it could call directly. A
second table names the same four lookups. They have to be bound lazily —
naming byId and friends in an object literal earlier in the same declaration
binds undefined, which is what made the first attempt at this fail — and the
namespace fetcher takes its arguments the other way round.

Throughput moves less than the deopt suggests: 1.31x on 'div:not(.x)' and
within a few percent either way elsewhere, measured interleaved. The reason
to keep both is that they remove work rather than trade it — one deopt per
call and two allocations per query — and neither changes a result: every
selector still agrees with the reference engine.
Two changes aimed at the selectors this engine is asked for in practice
rather than the ones CSS textbooks use.

Descent now accepts a class at any level — '.sidebar ul li a', 'ul li.row
a.link' — and asks for it by name. The selectors reaching a selector engine
are mostly generated now, and the generators agree on a narrow vocabulary:
atomic CSS (StyleX, nanocss, Tailwind) emits one short class per declaration
and stacks a dozen on an element, CSS modules ship a single hashed class per
element, and lightningcss lowers nesting and ':is()' into plain descendant
chains before a browser sees them. A class lookup by name is the selective
step in all of them, so fetchLevel() asks the host for the class and checks
the tag afterwards, on the few elements that come back.

Measured on 800 elements carrying 12 classes each, finding one class:
getElementsByClassName 0.042ms, a regular expression over the class attribute
0.123ms, a hand-rolled scan 0.141ms, classList.contains 0.297ms. The regular
expression the engine already used is the best of the per-element three, so
the win is in not testing per element at all.

  .sidebar ul li a       1.448ms -> 0.059ms   24x   (jsdom's engine 0.074ms)
  .sidebar .row .link    1.136ms -> 0.030ms   38x   (0.035ms)
  main section ul li a   0.994ms -> 0.605ms   1.6x  (1.023ms)
  .card ul li a          0.621ms -> 0.636ms         (declines, 400 entries)

Declining is now cheap. The first level's size is read off the live
collection before anything is copied, and a level is measured before it is
iterated rather than while a budget drains — the earlier form paid for most
of a wide level and threw the work away, which measured 3x slower than not
descending at all on '.app .card .row a'.

An escaped class such as the 'md\:flex' those same frameworks emit is
deliberately not accepted, and stays on the path that handles escapes.

Separately, '[data-testid="x"]' compares as a string. Built as a regular
expression it is compiled once but run per element against a value the host
already returns as a string; the comparison is 1.05-1.08x, small because the
cost is getAttribute rather than the match. It matters because it is the
shape testing-library asks for most.
Reading a tag name is the cheapest test the engine has. Measured over 6344
elements: localName 0.281ms, nodeName 0.341ms, hasAttribute 0.595ms,
getAttribute 0.687ms. So the tag is what should reject an element, and it
should do it first.

It was doing it last. The compiler wraps outward, so whatever is emitted last
runs first, and a compound is written tag-first — which put the dearest test
in front of the cheapest. 'div[data-x="1"]' called getAttribute on every
candidate before asking whether it was even a div. The tag test is now held
and applied when the compound ends, at a combinator or at the end of the
selector, which reverses the runtime order without moving it out of its own
compound.

  div.item[data-role]         0.522ms -> 0.411ms   1.27x
  span.item[data-k="7"]       0.517ms -> 0.444ms   1.16x
  div.item[data-role="row"]   0.610ms -> 0.529ms   1.15x
  div.item[data-k="900"]      0.526ms -> 0.481ms   1.09x

Only compounds that keep a tag test and something else are affected, since
the optimizer strips whichever token it fetched the candidates with: 'a[href]'
and 'p[class][id]' measure unchanged, because one has nothing left beside the
attribute and the other has two tests of equal cost.

Isolated, the ordering is worth more than it is here — 0.285ms against
0.822ms for tag before class, 0.494ms against 0.684ms for tag before
attribute — but most compounds reach the resolver with a single test left.
':not(.a)' compiled to a call back into match() for every candidate, which
costs a cache lookup and a resolver invocation to answer what one inlined
condition answers. A compound argument now compiles as its own conditions,
writing a flag the negation reads, so the candidate is tested where it is
already held.

An argument that carries a combinator or a comma keeps the call: walking
inside the negation would move the element the surrounding loop is holding,
and a list is what the forgiving path is for. Invalid arguments are reported
by the compiler instead of at match time, which is what the reference engine
does with them, and an argument left unclosed is still closed by EOF.
jsdom 30's engine keeps the result of a query until the document changes, so
a benchmark that asks for one selector in a loop times a lookup in a memo
against a real match. Three of the report's rows were that:
'div:not(:nth-of-type(2n))' read 0.019ms against our 0.136ms, and the same
query with one element appended and removed in between costs it 0.806ms
against our 0.904ms.

The report now measures every case both ways. The change is the smallest a
document can have, it leaves the document as it was, both engines pay it, and
its own cost is measured and subtracted. Rows where the two regimes disagree
by more than 4x are marked in the table and starred in the chart, and the
README says what the star means and why this engine keeps no such cache.
Three things the generated resolvers were paying for per candidate.

A class test called through the host to look the attribute up. The class
attribute is reflected as a property, and reading it measured 0.477ms against
0.770ms over 6344 elements. In a browser that reflection is an
SVGAnimatedString on an SVG element, so the type is checked and the attribute
asked for when it is not a string, which costs nothing measurable.

An attribute test asked the candidate for 'getAttribute' before calling it.
Matching is handed one node by a caller and keeps that guard; selecting works
through a list of elements the engine fetched itself, where the read only
confirms what the fetch guarantees, and dropping it measured 1.11x.

A selector whose only part was used to fetch the candidates still compiled a
resolver, which was a loop copying its input: 'div', '.example', or one item
of a list like 'label, [aria-label]'. There is no resolver for that now and
the caller keeps the list the fetch returned, which measured 1.03x on wide
selections and nothing on narrow ones.

Measured against the previous commit: 'div:not(.example)' 1.65x,
'.btn.primary' 1.31x, 'ul li.row a.link' 1.13x.
An id in a compound compiled to a regular expression over the attribute:
'/^title$/.test(e.getAttribute("id"))'. What the selector asks for is an
exact comparison, and the id is reflected as a property, so the test is now
'e.id=="title"' with the same fallback the class test uses when the
reflection is not a string. Measured over 6344 elements: 0.383ms against
0.717ms, and '#root .card' 1.17x end to end.

The escapes have to survive that change, since the comparison holds a
string where the pattern held a pattern, so a test covers a compound id
escaped every way the syntax allows. The report grows the case that reaches
this code at all: the optimizer fetches candidates by the rightmost part, so
a plain '#id' is a lookup and never a comparison.
The branch has accumulated measurements in commit messages, where nobody
reads them before trying the same idea again. docs/performance.md collects
them: where the time actually goes (calls across the host boundary, with the
cost of each read), the rules that paid, and a table of the ideas that were
implemented, measured and thrown away — greedy ancestor walks, a
document-order stack walk, item()/Array.from, classList.contains, and the
others.

The deopt check in it is corrected against a run rather than quoted from
memory: a full report prints one Resolver deopt, and what must not appear is
'out of bounds' or a count that scales with the queries. The host-read table
is re-measured so every row is one bare read, since the class and id numbers
elsewhere include the pattern or comparison around them.
standing.svg and gains.svg are regenerated with upstream 2.2.27 as the
baseline, so the charts show where the branch stands against both the release
it started from and the engine jsdom ships. The README table is taken from
that run: with the document changed between queries, the pseudo-class shape
that reads as a 7x loss on repeated queries is now level.
The generated tests were carrying two fallbacks for hosts that cannot run
this source at all. It is written with arrow functions and Map, so nothing
before 2015 executes it, and every host that can returns elements from a tag
or class lookup and reflects id as a string.

Config.LEGACY, off by default, is where that handling lives now. With it off
an attribute test calls getAttribute directly and an id test compares e.id;
with it on both ask the candidate for the method first, the way every version
up to 2.2.27 did, so a collection holding a comment node — IE up to 8 put
them in a '*' collection — is a non-match rather than a TypeError. Matching
keeps the guard either way, since that is where a caller's own node arrives.
Under the option, match() on a comment node now answers false for a class or
id selector, which upstream threw on.

Changing LEGACY or FORGIVING clears the compiled resolvers, since both are
read while a selector compiles and a resolver built under the old value would
otherwise answer the next query with it.

The one case that is not legacy stays in the default path: SVGElement
reflects className as an SVGAnimatedString, which SVG 2 deprecated and every
browser and jsdom still ship. It moves into classOf() rather than behind the
option, so the rare branch is written once instead of into every resolver
that tests a class. Measured against the inline check that call is free —
0.504ms, where inline is 0.557ms and no check at all is 0.517ms — and the
whole report is unchanged.
The reason given for the option was that the source uses arrow functions and
Map and so cannot run before 2015. That is not a reason: a build tool lowers
syntax, and both collections have polyfills, so the way this file is written
sets no floor for where it runs.

What a build tool cannot do is change what the host hands back, and that is
what the option is for: a collection holding a comment node keeps holding one.
The floor that does exist is the DOM this engine calls and nobody can supply —
getAttributeNames() for namespaced attribute selectors, isConnected for
:lang(), closest when installing over the host, and IE 9-era element traversal
for the fetch and the walks. docs/performance.md now states that as a table,
and says plainly that LEGACY is the only option this work adds: FORGIVING is
upstream's, and it is about a selector list rather than a host.

No behavior change.
'Legacy' was doing the work of a fact. The quirk LEGACY exists for belongs to
IE 8, which shipped in March 2009: Node.js was two months from its first
release, npm was a year away, and Game of Thrones was two years from airing.
IE 9 stopped putting comment nodes in a '*' collection 15 years ago, and the
last IE was retired in June 2022. The floor table gives the same treatment to
the DOM this engine needs, in years rather than version numbers: about 15 for
what most selectors use, about 9 for the newest feature.

The README's option list also described two flags that do not exist in Config,
LIVECACHE and MIXEDCASE, and omitted three that do. It now lists the real set
with what each one does, each checked by running it: VERBOSITY false answers an
invalid selector as no match rather than throwing, FORGIVING false throws on an
item ':is()' cannot read, and NODE_LIST reads NodeList off the global object,
so it only works where that is the host's own global.
The floor table said which browsers are below it without saying how many
there are. From caniuse-lite 1.0.30001734, whose newest browser release is
dated August 2025, as a share of the 96.7% of usage it records: IE 8 and
older is 0.03%, all Internet Explorer is 0.67%, anything released before
September 2017 — the newest DOM feature this engine needs — is 1.09%.

So LEGACY is for about three page views in ten thousand, which is the argument
for the option rather than against it: those three get the old handling from
one flag, and nobody else pays a property read per candidate for them. The
snippet that produced the numbers is in the doc, since the number moves.
The share came from a caniuse-lite copy that happened to be in a sibling
repo and was a year old. It is a devDependency now, through a pnpm catalog
entry, and scripts/browser-share.mjs prints the numbers so the doc can be
refreshed with 'pnpm run browsers:share' rather than rewritten.

The current data answers differently, and more precisely. Globally, IE 8 and
older is 0.0000%: what is left of Internet Explorer is 0.2663% and all of it
is IE 9 to 11, which put elements in an element collection like everything
else. The per-place tables, which are sampled separately and are coarser, do
still record some — China 0.900%, Ireland 0.357% of IE 7, Japan 0.154%,
Russia 0.088%, Taiwan 0.050%, and seven places under 0.02% — twelve of 232 in
all. Weighted by how many people are online in each, that is on the order of
ten million, 97% of it the one Chinese line item, against a global sample that
records nobody. A year-older dataset put the global figure at 0.0332%, so it
is shrinking whichever view is closer.
…bers

The timing, the documents, the world builder and the charts were written once
in report.mjs and again, differently, in the other benchmarks. They now live
in bench/lib/ and report.mjs went from 457 lines to 254 by using them.

bench/accessors.bench.mjs is new, and it produces the numbers docs quote for
the per-element cost of a host read and for the spelling of each generated
test. Before this they came from throwaway scripts, which is why the doc could
not be refreshed without rewriting it; 'pnpm run bench:accessors' prints the
tables and --markdown prints them ready to paste.

One number it prints disagrees with what the doc says. A hand-rolled scan of
e.className now measures 0.565ms against 0.746ms for the regular expression,
where the older measurement had the regular expression ahead. The difference
is that the value now comes from a property rather than from getAttribute, so
the scan is no longer competing with a host call. Left as a finding for now,
since changing the generated class test needs the quirks-mode case folding
handled too.
The script printed shares; the claims around them - how old IE 8 is, which
DOM features set the floor, how many people that adds up to - were written by
hand in the doc. It now computes all of them.

Ages come from the release dates in the data, so 'IE 8 shipped 2009-03-19, 17
years ago' is read rather than typed. Each thing the engine needs from a host
gets a row: the caniuse-tracked ones read their support table, and the two it
does not track, getAttributeNames() and isConnected, carry the MDN versions
with a date beside them and count a browser as having the feature when it
shipped later. That correction matters: counting by a version table with no
row for any mobile browser had them all lacking, which read as 64% of usage
instead of 0.82%.

Places are ranked by people rather than by share, since a large share of a
small place is fewer people than a small share of a large one, using a table
of internet-user figures with its source named. --markdown prints the tables
for the doc, and --places sets how many rows.
The script now says where each number comes from - caniuse-lite for usage,
release dates, per-place shares and feature support; MDN for the two features
caniuse does not track; ITU and national figures, with their year, for the
population counts - and it prints that provenance under every run rather than
burying it in a comment.

It also reports how old the data is and tells you how to refresh it: read the
published version, put it in the pnpm catalog, install, re-run with
--markdown. Anything older than a quarter counts as stale, and --check exits
non-zero on that so CI can hold the line. Right now the newest browser release
in the data is 37 days old.

Also splits every console.log that opened with a newline into a blank log line
and the line itself, across the benchmarks and scripts.
The option used to restore one guard. It now selects a different set of host
reads throughout, so a selector answers correctly on a host that has no
hasAttribute, no getElementsByClassName, no getAttributeNames, no isConnected,
no element-only traversal and no localName, and whose tag collection carries
comment nodes.

How it is wired. The generated code reads the host through one of three
tables: the properties written in place, the same with the attribute guard for
match(), or a helper per read. The table is chosen once while a selector
compiles, and the helpers a resolver uses are declared as locals in its own
head, so a candidate costs one call rather than a property load and a call.
The engine's own loops keep their direct reads and take a second loop for the
legacy case, because those run per sibling rather than per query. The report
measures unchanged against the previous commit on every case.

What the helpers know is mostly the attribute-versus-property split that
jQuery's propFix and attrHooks, and David Mark's My-Library, existed to
handle: getAttribute answered through the property, so 'class' and 'for' were
reachable only as className and htmlFor, a URL attribute came back resolved
unless the second argument asked for the markup, style came back as an object
and a boolean attribute as true or false, and 'specified' on the attribute
node was what separated a set attribute from an unset one.

Two optimizations stay off under the option, the ancestor filter and the
descent, because both only skip work and both would otherwise put a helper
call in a per-element loop. makeref() no longer reads classList, which was
the engine's only use of it, so that requirement is gone from the floor
entirely.

Tested against a host shaped like those browsers: test/node/legacy-host.mjs
hides the modern APIs over a jsdom document and puts the old answers back, and
test/node/legacy.spec.mjs runs 64 selector shapes plus match, first, closest
and scoped queries through it, checking each against what jsdom's own
querySelectorAll says about the same markup.
docs/legacy.md is new: what Config.LEGACY changes, the quirk table with the
sources behind each row, what it cannot supply, how the test double stands in
for browsers nobody can run here, and who it is for. The README's paragraph
described only the guard it used to restore, so it now describes the layer and
points at the doc.

docs/performance.md takes its tables from the scripts rather than from the
last time somebody edited it: the host-read costs and the guard number from
bench/accessors.bench.mjs, the floor and usage figures from
scripts/browser-share.mjs, both with the command beside them.

That also settles the finding the earlier run opened. With five rounds the
regular expression, a hand-rolled scan and the helper call are level on a
class test — 0.601, 0.588 and 0.578 ms — so what mattered was reading the
class from a property rather than from getAttribute, not the spelling of the
test, and the generated code stays as it is.
The archived page, linked from the helpers and from docs/legacy.md, is
"A is for Attributes":
https://web.archive.org/web/20091217095816/http://www.cinsoft.net/attributes.html

It tested these behaviors across the browsers of the day, and it corrects
three things this branch had wrong.

A missing attribute could answer a property default: IE 6 and 7 answered
getAttribute('enctype') with the form's default when the markup had set
nothing. So a value can no longer decide presence. Where the host keeps an
attributes collection, that collection and its 'specified' flag decide, and a
value is only read afterwards.

A boolean attribute cannot be told apart from its long form. The host answers
the property, so '<input checked>' and '<input checked="checked">' read the
same, and this now reports the empty string for both, the way he does. That is
the markup of the bare form, so '[checked]' and '[checked=""]' both work and
both agree with the reference engine, where reporting the attribute name
agreed with neither.

URL resolution was not one browser's bug. IE up to 7 resolved URL attributes
and took a second argument to ask for the markup; Opera up to 9.27 resolved a
form action with no way to ask, and 8.54 resolved six of them. Passing the
second argument and trusting it was therefore wrong for half the hosts it was
meant for, so the read is now probed once per document with a relative URL:
the second argument, the attribute node, or the ordinary read.

The test double grows a 'urls' option for that second shape and a property
default for enctype, and there are three new tests. The report measures
unchanged.
An audit of the generated code found eight kinds of host read that a legacy
resolver still made directly, all of them in the pseudo-class emissions:
localName in twenty of them, plus id, isConnected, getAttribute, hasAttribute
and parentElement. Converting thirty sites by hand is how the next one gets
missed, so a legacy resolver now takes one pass over the code it generated and
rewrites the reads it recognizes into the same helper calls. The audit is a
test: no legacy resolver may contain a direct host read.

isContentEditable() read the host directly as well, which made ':read-write'
throw on such a host rather than answer.

The audit also turned up two bugs in the ordinary path, neither of them about
legacy hosts.

':enabled' read only the element's own disabled property, so an input inside a
disabled fieldset matched ':enabled' and ':disabled' both. The rule now lives
in one isDisabled() helper that both ask, and it follows the spec rather than
approximating it: a disabled fieldset disables its descendants unless they sit
in that fieldset's first legend child, a legend only excuses the fieldset it
belongs to, and an option is disabled by the optgroup it is a child of. Every
shape of that agrees with the reference engine now, and nothing is both
enabled and disabled.

':defined' asked the custom element registry about every element, so it
matched only upgraded custom elements and nothing else - on any host. Every
built-in element is defined; only a custom element can be undefined, and only
until a definition exists and it has been upgraded. It reads the hyphen in the
name first and asks for the 'is' attribute only when there is none.

The report measures unchanged on every case.
A differential test against Chromium, in test/upstream/browser-agreement.spec.mjs,
turned up four wrong answers. The WPT suite does not reach them: it has
nothing for ':defined' at all, and the form-state selectors it does cover
leave these shapes out.

':optional' skipped button elements, which the HTML spec lists first among
the ones it matches. Its pattern was also written '/^input|select|textarea$/',
which reads as '^input' or 'select' or 'textarea$' and matched by accident;
both it and ':required' are anchored groups now.

':read-write' and ':read-only' read the element's own disabled property, so a
control inside a disabled fieldset was read-write. They ask isDisabled() now,
the same helper ':disabled' uses.

A fieldset was ':valid' when it contained a ':valid' control. The rule is that
none of its controls may be invalid, which is a different thing: a fieldset
holding no validation candidates at all is valid, and was previously neither
valid nor invalid.

On all twelve of these selectors this engine now answers exactly what Chromium
answers. jsdom's engine differs from Chromium on four of them, and the header
of the new spec records why that is not a reason to change anything here:
jsdom's DOM reports willValidate false for a disabled control, the same as a
browser, while @asamuzakjp/dom-selector 8.3.0 does not read willValidate at
all and decides ':valid' from validity.valid alone.
The four answers the browser corrected are now cited where they are
implemented, at a tag so the lines keep meaning what they mean, rather than
described from memory. Fourteen references in src/nwsapi.js and the dispatch
table in the browser spec's header, each one checked: the file resolves at
that tag and the line is inside it.

The two that read as designed rather than as accident:

Blink runs ':enabled' and ':disabled' off one predicate,
MatchesEnabledPseudoClass() being !IsDisabledFormControl(), which is what
sharing isDisabled() here achieves.

Its ancestry walk keeps a legend ancestor and compares it against that
fieldset's own first legend before carrying on outward, which is the subtlety
this engine had to get right and the reason the walk does not stop at the
first disabled fieldset it excuses.
scripts/engine-differences.mjs collects four answers for every selector: the
browser's own engine, this engine in the same page, jsdom's engine, and this
engine on jsdom. Four answers instead of two is what lets a difference be
attributed rather than argued about - this engine being wrong, jsdom's engine
being wrong, or jsdom's DOM being unable to express the state - and the script
exits non-zero only on the first of those.

On the current fixtures seven selectors differ, all of them jsdom's engine
against the browser, none of them ours. docs/dom-selector-differences.md
records them with the cause of each and the Blink lines that decide them:
validity ignoring willValidate, ':read-write' and ':read-only' reading an
element's own disabled property, and ':optional' skipping buttons. Three of
the seven are those same two causes seen through a compound, which is the
reason to list them separately.

None of it has been reported upstream, and the doc says so in as many words.

Two node tests pin the answers so they can be checked without a browser, with
the expected sets taken from Chromium rather than from jsdom, since jsdom is
the one that disagrees on exactly these.

Getting the harness right took three passes worth recording: a fragment-only
navigation does not reload a page, so every fixture was measured against the
first one; the script tag that injects the engine shows up as an element the
browser has and jsdom does not; and it makes the browser's <head> non-empty,
which reads as a ':empty' difference. All three are filtered now.
Four patches, each against upstream master on its own and touching
src/nwsapi.js only, for the pseudo-class bugs a browser comparison found:

  disabled-complement  ':enabled' the complement of ':disabled', with the
                       fieldset, legend and optgroup rules in one helper the
                       two ':read-*' pseudo-classes ask as well
  optional-anchors     anchor the ':required' and ':optional' patterns, and
                       let ':optional' take a button
  valid-fieldset       a fieldset is valid when none of its controls is
                       invalid, not when one of them is valid
  defined-built-ins    every built-in element is ':defined'

The verifier grows a second document for them, because the controls and
fieldsets they need would change what the older probes see, and asserts each
patch against it: what upstream answers, what the patch answers, and that
nothing else moved. On that document upstream matches 'fi1' with both
':enabled' and ':disabled', matches no button with ':optional', matches
nothing at all with ':defined', and calls neither an empty fieldset nor one
inside a disabled fieldset ':valid'. Each patch fixes one of those and leaves
the rest as upstream has them.
@jdalton
jdalton force-pushed the selectors4-modernization branch from 9ed7c37 to 863101b Compare September 4, 2026 17:00
Sixteen patches against the same file collided in seven pairs, and most of
those collisions were nothing but two patches inserting at the same anchor.
Measuring which is which took a matrix: apply patch A to upstream master,
then check whether B still applies, for every ordered pair.

Four anchors moved. The ancestor filter's helpers now sit by isHTML rather
than by isPlaying, where the link test goes; the disabled helper sits by
isFocusable rather than by isContentEditable, where the defined test goes;
argsWith sits by install(), the wrappers it serves, rather than on the slice
declaration the captured matcher uses; and reSimpleId sits in the Patterns
block rather than above reOptimizer, which the optimizer patch rewrites.
Three exports moved to spaced-out keys in the Snapshot list, since fifteen
one-line keys cannot hold four insertions without their context overlapping.

The result, cherry-picked onto master one after another: sixteen of sixteen
land with no conflict, in PR order and in reverse. The integrated build
answers ':enabled', ':disabled', 'fieldset:valid' and ':defined' the way
Chromium does, so the patches compose and not merely apply.
Each patch message now ends with a references block: the spec that defines
the behavior, the Chromium line that implements it, MDN where a reader wants
the prose version, and whatever else the change was reasoned from. Sixteen
patches, thirty-two distinct links.

A dead link in a pull request is worse than no link, and a pinned Chromium
line that has drifted past the end of its file is worse still, so
scripts/check-references.mjs checks both: the URL has to answer 200, and a
'#L<n>' has to be inside the file it points at. It reports which patch cites
anything that fails. Right now all thirty-two resolve and every pinned line
is inside its file.

The Chromium tree is pinned at a tag rather than a branch, which is what
makes a line number worth citing at all.
The seventeenth patch in the set, and the last piece of this branch's
generated-code work that upstream can take on its own: a class test reads
Element.className through a helper that falls back to the attribute for the
SVG reflection, and an id test compares e.id instead of matching a regular
expression over getAttribute('id').

Measured against upstream master, interleaved, on the 6344-element fixture:
'div.example > p' 1.25x, '#title p' 1.29x, '.example a' 1.11x, and no change
on '.example' or 'div.example', where the fetch answers the selector and no
resolver runs. Eleven selectors covering classes, escaped ids, SVG classes
and a negation answer the same as jsdom before and after.

Its references are checked with the rest: the DOM lines that define the two
reflections, the SVG one that is not a string, and the Blink lines showing a
class matched against a parsed token list and an id compared for equality.
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