Add Selectors 4 state pseudo-classes, upstream WPT runner, modern toolchain - #167
Open
jdalton wants to merge 46 commits into
Open
Add Selectors 4 state pseudo-classes, upstream WPT runner, modern toolchain#167jdalton wants to merge 46 commits into
jdalton wants to merge 46 commits into
Conversation
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$/
This was referenced Sep 4, 2026
jdalton
force-pushed
the
selectors4-modernization
branch
from
September 4, 2026 02:47
1c8049f to
9ed7c37
Compare
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.
This was referenced Sep 4, 2026
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
force-pushed
the
selectors4-modernization
branch
from
September 4, 2026 17:00
9ed7c37 to
863101b
Compare
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.
jdalton
force-pushed
the
selectors4-modernization
branch
from
September 5, 2026 01:05
863101b to
2cec953
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
src/nwsapi.js:open,:modal,:fullscreen,:picture-in-picture, and the time-dimensional:current/:past/:futuretest/upstreambenchtest/speedBenchmark.js presets ported to mitata, 9 groups and 201 selectorsThis 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
:closedis absent on purposeThe state pseudo-classes are wired against the elements that can actually carry the state, so
:modaland:fullscreendo not match arbitrary nodes. The media-state pseudo-classes are scoped to media elements for the same reason, andisPlayingis exposed on the Snapshot so the check has something to read.:current,:past, and:futureparse 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.:closedis 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
isInstanceOftypo, 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:noneweb-platform-tests checkout pinned in.gitmodules. There is no gitlink committed. Therefis the pin of record, alongside anls-treemanifest sha256, so the pin is reviewable as text rather than as an opaque commit id.scripts/git-partial-submodule.mjsdrives it with no dependencies and supportsclone,verify,verify --deep, andrestore-sparse. Its argv handling is hardened, since it shells out to git.The runner is filterable while you work on one thing:
WPT_FILTERnarrows to an individual selector andWPT_SECTIONnarrows 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, withengineStrictandsaveExactset inpnpm-workspace.yamlso dependency versions stay exact.engines.nodeis>= 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,
portlessfrom vercel-labs serves the pages athttps://nwsapi.localhost, which is what the browser tests point at.