From e86a2fc9b543153c2c68dcdc7d58d80546fca174 Mon Sep 17 00:00:00 2001 From: zirkelc Date: Tue, 22 Sep 2026 19:40:19 +0200 Subject: [PATCH 1/2] perf(token-list): cache the joined class value until a token changes --- cjs/dom/token-list.js | 24 ++++++++++++++++++++++-- esm/dom/token-list.js | 24 ++++++++++++++++++++++-- types/esm/dom/token-list.d.ts | 8 +++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/cjs/dom/token-list.js b/cjs/dom/token-list.js index 25f70f1d..33f1ff73 100644 --- a/cjs/dom/token-list.js +++ b/cjs/dom/token-list.js @@ -1,5 +1,5 @@ 'use strict'; -const {OWNER_ELEMENT} = require('../shared/symbols.js'); +const {OWNER_ELEMENT, VALUE} = require('../shared/symbols.js'); const {setAttribute} = require('../shared/attributes.js'); const {Attr} = require('../interface/attr.js'); @@ -10,6 +10,7 @@ const addTokens = (self, tokens) => { if (token) add.call(self, token); } + self[VALUE] = null; }; const update = ({[OWNER_ELEMENT]: ownerElement, value}) => { @@ -31,6 +32,8 @@ class DOMTokenList extends Set { constructor(ownerElement) { super(); this[OWNER_ELEMENT] = ownerElement; + // the joined value is asked for by every class selector: keep it until a token changes + this[VALUE] = null; const attribute = ownerElement.getAttributeNode('class'); if (attribute) addTokens(this, attribute.value.split(/\s+/)); @@ -38,7 +41,22 @@ class DOMTokenList extends Set { get length() { return this.size; } - get value() { return [...this].join(' '); } + get value() { + return this[VALUE] || (this[VALUE] = [...this].join(' ')); + } + + clear() { + this[VALUE] = null; + super.clear(); + } + + /** + * @param {string} token + */ + delete(token) { + this[VALUE] = null; + return super.delete(token); + } /** * @param {...string} tokens @@ -74,6 +92,7 @@ class DOMTokenList extends Set { update(this); } else if (force || arguments.length === 1) { + this[VALUE] = null; super.add(token); update(this); return true; @@ -89,6 +108,7 @@ class DOMTokenList extends Set { if (this.has(token)) { this.delete(token); super.add(newToken); + this[VALUE] = null; update(this); return true; } diff --git a/esm/dom/token-list.js b/esm/dom/token-list.js index 27c19ff7..c9659ffd 100644 --- a/esm/dom/token-list.js +++ b/esm/dom/token-list.js @@ -1,4 +1,4 @@ -import {OWNER_ELEMENT} from '../shared/symbols.js'; +import {OWNER_ELEMENT, VALUE} from '../shared/symbols.js'; import {setAttribute} from '../shared/attributes.js'; import {Attr} from '../interface/attr.js'; @@ -9,6 +9,7 @@ const addTokens = (self, tokens) => { if (token) add.call(self, token); } + self[VALUE] = null; }; const update = ({[OWNER_ELEMENT]: ownerElement, value}) => { @@ -30,6 +31,8 @@ export class DOMTokenList extends Set { constructor(ownerElement) { super(); this[OWNER_ELEMENT] = ownerElement; + // the joined value is asked for by every class selector: keep it until a token changes + this[VALUE] = null; const attribute = ownerElement.getAttributeNode('class'); if (attribute) addTokens(this, attribute.value.split(/\s+/)); @@ -37,7 +40,22 @@ export class DOMTokenList extends Set { get length() { return this.size; } - get value() { return [...this].join(' '); } + get value() { + return this[VALUE] || (this[VALUE] = [...this].join(' ')); + } + + clear() { + this[VALUE] = null; + super.clear(); + } + + /** + * @param {string} token + */ + delete(token) { + this[VALUE] = null; + return super.delete(token); + } /** * @param {...string} tokens @@ -73,6 +91,7 @@ export class DOMTokenList extends Set { update(this); } else if (force || arguments.length === 1) { + this[VALUE] = null; super.add(token); update(this); return true; @@ -88,6 +107,7 @@ export class DOMTokenList extends Set { if (this.has(token)) { this.delete(token); super.add(newToken); + this[VALUE] = null; update(this); return true; } diff --git a/types/esm/dom/token-list.d.ts b/types/esm/dom/token-list.d.ts index d4538bd5..e6664219 100644 --- a/types/esm/dom/token-list.d.ts +++ b/types/esm/dom/token-list.d.ts @@ -4,7 +4,11 @@ export class DOMTokenList extends Set implements globalThis.DOMTokenList { constructor(ownerElement: any); get length(): number; - get value(): string; + get value(): any; + /** + * @param {string} token + */ + delete(token: string): boolean; /** * @param {...string} tokens */ @@ -31,6 +35,4 @@ export class DOMTokenList extends Set implements globalThis.DOMTokenList { * @param {string} token */ supports(): boolean; - [OWNER_ELEMENT]: any; } -import { OWNER_ELEMENT } from '../shared/symbols.js'; From c7bb078a42c879873ecd36b5a1172adb2e7ced2e Mon Sep 17 00:00:00 2001 From: zirkelc Date: Tue, 22 Sep 2026 20:15:03 +0200 Subject: [PATCH 2/2] perf(parser): normalise class values without a token list, single lookup for html classes --- cjs/html/document.js | 5 +++-- cjs/shared/parse-from-string.js | 13 ++++++++++++- esm/html/document.js | 5 +++-- esm/shared/parse-from-string.js | 13 ++++++++++++- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/cjs/html/document.js b/cjs/html/document.js index 85f1a4f8..1274dd98 100644 --- a/cjs/html/document.js +++ b/cjs/html/document.js @@ -10,9 +10,10 @@ const {customElements} = require('../interface/custom-element-registry.js'); const {HTMLElement} = require('./element.js'); const createHTMLElement = (ownerDocument, builtin, localName, options) => { - if (!builtin && htmlClasses.has(localName)) { + if (!builtin) { const Class = htmlClasses.get(localName); - return new Class(ownerDocument, localName); + if (Class) + return new Class(ownerDocument, localName); } const {[CUSTOM_ELEMENTS]: {active, registry}} = ownerDocument; if (active) { diff --git a/cjs/shared/parse-from-string.js b/cjs/shared/parse-from-string.js index 84a9fdf3..f85f205c 100644 --- a/cjs/shared/parse-from-string.js +++ b/cjs/shared/parse-from-string.js @@ -27,12 +27,23 @@ const append = (self, node, active) => { return node; }; +/** Same tokens as a DOMTokenList would hold: no empty entries, no duplicates, single spaces. */ +const classTokens = value => { + const tokens = []; + for (const token of value.split(/\s+/)) { + if (token && !tokens.includes(token)) + tokens.push(token); + } + return tokens.join(' '); +}; + const attribute = (element, end, attribute, value, active) => { attribute[VALUE] = value; attribute.ownerElement = element; knownSiblings(end[PREV], attribute, end); + // the token list is built on demand: while it does not exist, only the value needs the tokens if (attribute.name === 'class') - element.className = value; + attribute[VALUE] = classTokens(value); if (active) attributeChangedCallback(element, attribute.name, null, value); }; diff --git a/esm/html/document.js b/esm/html/document.js index 47e23bca..ea6a9a3c 100644 --- a/esm/html/document.js +++ b/esm/html/document.js @@ -9,9 +9,10 @@ import {customElements} from '../interface/custom-element-registry.js'; import {HTMLElement} from './element.js'; const createHTMLElement = (ownerDocument, builtin, localName, options) => { - if (!builtin && htmlClasses.has(localName)) { + if (!builtin) { const Class = htmlClasses.get(localName); - return new Class(ownerDocument, localName); + if (Class) + return new Class(ownerDocument, localName); } const {[CUSTOM_ELEMENTS]: {active, registry}} = ownerDocument; if (active) { diff --git a/esm/shared/parse-from-string.js b/esm/shared/parse-from-string.js index 08c6fe39..af30aac2 100644 --- a/esm/shared/parse-from-string.js +++ b/esm/shared/parse-from-string.js @@ -26,12 +26,23 @@ const append = (self, node, active) => { return node; }; +/** Same tokens as a DOMTokenList would hold: no empty entries, no duplicates, single spaces. */ +const classTokens = value => { + const tokens = []; + for (const token of value.split(/\s+/)) { + if (token && !tokens.includes(token)) + tokens.push(token); + } + return tokens.join(' '); +}; + const attribute = (element, end, attribute, value, active) => { attribute[VALUE] = value; attribute.ownerElement = element; knownSiblings(end[PREV], attribute, end); + // the token list is built on demand: while it does not exist, only the value needs the tokens if (attribute.name === 'class') - element.className = value; + attribute[VALUE] = classTokens(value); if (active) attributeChangedCallback(element, attribute.name, null, value); };