Conversation
This was referenced Sep 23, 2026
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.
What this PR does
Two commits, both about the
classattribute.DOMTokenList: keep the joined value until a token changes.get value()rebuilt the string with[...this].join(' ')on every read. css-select asks for it through the adapter on every element it tests against a class selector, so aquerySelectorAll('.foo')over a large document joins the same tokens thousands of times. The value is now kept in the instance and dropped again in every path that changes a token:addTokens,toggle,replace, and the inheritedclearanddelete, which are overridden for that reason (remove()andclassName's setter go through them). The public value can therefore not drift from the token set.Parser: normalise a class value without building a token list. While parsing,
attribute()inparse-from-string.jscalledelement.className = value, which built aDOMTokenList, split the value, added every token, joined them again and wrote the result back through theAttrvalue setter. The only lasting effect of that round trip is the normalised attribute value (no empty entries, no duplicates, single spaces), whichclassTokens()now produces directly. The token list itself is built lazily, by the existingclassListgetter, for the elements that actually need one. The same commit replaceshtmlClasses.has(localName)followed byhtmlClasses.get(localName)with a singlegetincreateHTMLElement.Verification (this branch vs
main)main/pages/*)documentElement.cloneNode(true))content.jssequence ondom.html)clone-deepimproves although this PR does not touch cloning: elements no longer carry a tokenlist built during parsing, so there is less per-element state to walk.
parse-shopis the casethe second commit targets, but its two runs disagree (-1.0% and -6.4%), so I do not claim a
number for it; the parse effect is visible in the extraction cases, which parse a page per
iteration as well.
Observable surface
DOMTokenListinstances carry one more internal field, keyed by the existingVALUEsymbol fromshared/symbols.js. It is not enumerable infor...inover the instance (symbol key), does not appear inObject.keys, and does not changeSetiteration,sizeorlength.clear()anddelete()are now own methods ofDOMTokenListinstead of inheritedSetmethods. They callsuper, so the return values and semantics are those ofSet. Code that comparesclassList.delete === Set.prototype.deletewould see a difference.classList.valuereturns the same string as before in every path, including afteradd,remove,toggle,replace,className = ...andremoveAttribute('class').classattribute values are normalised exactly as before: the tokens, their order and the single-space joining are those aDOMTokenListproduces.getAttribute('class'),classNameand the serialized output are unchanged (the characterisation guard hashes the fullouterHTMLof 14 real pages).attributeChangedCallback(element, name, null, value)); the removed round trip through theAttrsetter fired no observer records during parsing.npm test's MutationObserver benchmark reports the same count (2) before and after.element.classListis now created on first use instead of during parsing. It is the same object on every later access, as before.Reproducing the numbers
The harness lives on the campaign branch of my fork:
zirkelc/linkedom@perf/autoresearch. It holdsperf/*.mts(A/B harness, characterisation guard, memory harness, profiler, the 14 cases),perf/plan.md(method, calibration, every experiment and why it was kept or discarded) andperf/experiments.tsv(the log, including the 10 discarded experiments and their numbers).One run takes about 4.5 minutes on an idle machine. Judge a case only when both runs agree; the suite total is the headline number, and
GEOMEANweights every case equally. The fixtures are live pages, so a fresh download changes the absolute milliseconds (not the ratios);perf/fetch-fixtures.shlists the URLs.Companion PRs from the same campaign, independent of each other and of this one:
Stacked, the four together measure 2.02x on the suite total and -62% on retained heap per document; on
npm run benchmark:html(the 12 MB page) parsing goes from 767/711 ms to 259/235 ms,cloneNode(true)from 591/527 ms to 84/85 ms and the total benchmark time from 4.23/4.09 s to 2.43/2.41 s. Each PR can be taken or left on its own; the numbers in each body are that branch measured alone againstmain.