From dbcd7d3da75a20cc500b4c3cfea2e23f3661aafb Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 13 Sep 2026 11:22:58 +0800 Subject: [PATCH] =?UTF-8?q?perf(ranui):=20build=20an=20SSR=20element=20in?= =?UTF-8?q?=200.034=C2=B5s=20instead=20of=201.389=C2=B5s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HTMLElementMock` held `style` and `classList` as instance fields containing object literals of arrow functions. An arrow function closes over `this`, so it cannot live on the prototype: every element allocated two objects and six closures in its constructor, plus a Map for event listeners and another for inline styles — whether or not anything ever touched them. Almost nothing does. A generated page is overwhelmingly elements with a class attribute and a text child. They are created on first access now, and `inlineStyles` and `eventListeners` on first write. Measured, `new HTMLElementMock('div')` plus two attributes: before 1.389 µs after 0.034 µs 41x End to end, building and serialising a realistic 209-node sidebar 1,393 times — the docs site's page count: before 710 ms after 241 ms 2.9x of which build 494 → 80 ms 6.2x The bottleneck is now serialisation, which is the work that actually has to happen. Output is byte-identical before and after, checked against the equivalent string-template rendering. How this was found is worth recording, because my first two explanations were wrong. I said the cost was "five objects and two Maps per node"; a standalone class of exactly that shape measures 0.060 µs, or 3.5% of it. Bisecting each chained call showed `new HTMLElementMock()` alone accounted for 93%, with `.class()`, `.attr()` and `.text()` adding a tenth of a microsecond between them. Only then did reading the constructor make the closures obvious. `inlineStyles` is now a `ReadonlyMap`: the empty map is shared by every element that never set a style, so writing through it would leak one element's styles into all of them. Nothing outside the mock writes to it, and the existing tests read it with `.get()` and `.has()`, which the read-only type allows. Nothing enumerates an element's own properties — the one thing that would have noticed a field becoming a getter. 1,732 unit tests and 254 SSR tests pass unchanged. This is the SSR path only. In a browser the builder wraps real elements from `document.createElement`, where `style` and `classList` are native; a component costs ~15.6µs to construct there against 0.85µs for a plain element, which is a separate question this does not touch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d --- packages/ranui/utils/builder/mocks.ts | 122 ++++++++++++++++++-------- 1 file changed, 86 insertions(+), 36 deletions(-) diff --git a/packages/ranui/utils/builder/mocks.ts b/packages/ranui/utils/builder/mocks.ts index 466b1ecee..100b6d804 100644 --- a/packages/ranui/utils/builder/mocks.ts +++ b/packages/ranui/utils/builder/mocks.ts @@ -34,10 +34,30 @@ export class DocumentFragmentMock { /** * A robust mock of HTMLElement for SSR environments. */ +/** Handed back by `inlineStyles` when nothing was ever set. Never mutated. */ +const EMPTY_STRING_MAP: ReadonlyMap = new Map(); + export class HTMLElementMock { public tagName: string; public attributes: Map = new Map(); - public inlineStyles: Map = new Map(); + /** + * Created on first write, like the two façades below. + * + * Only `style.setProperty` fills this, and `serialize()` is the only reader, so a node + * that never sets an inline style never needs the Map. + */ + private _inlineStyles: Map | null = null; + + /** + * The styles actually set, or an empty map — reading never allocates. + * + * Read-only because the shared empty map is handed to every element that never set a + * style; writing through it would leak one element's styles into all of them. Set + * styles with `style.setProperty`, which is what a browser requires anyway. + */ + get inlineStyles(): ReadonlyMap { + return this._inlineStyles ?? EMPTY_STRING_MAP; + } public childrenList: (HTMLElementMock | string | DocumentFragmentMock)[] = []; public shadowRoot: ShadowRootMock | null = null; private _textContent: string | null = null; @@ -59,33 +79,60 @@ export class HTMLElementMock { this._textContent = value; } public content?: DocumentFragmentMock; - private eventListeners: Map> = new Map(); - - public style = { - setProperty: (k: string, v: string): void => { - const prop = k.startsWith('--') ? k : k.replace(/([A-Z])/g, '-$1').toLowerCase(); - this.inlineStyles.set(prop, v); - }, - removeProperty: (k: string): boolean => this.inlineStyles.delete(k), - }; - - public classList = { - add: (...names: string[]): void => { - const existing = this.attributes.get('class') || ''; - const list = new Set([...existing.split(' ').filter(Boolean), ...names]); - this.attributes.set('class', Array.from(list).join(' ')); - }, - remove: (...names: string[]): void => { - const existing = this.attributes.get('class') || ''; - const list = existing.split(' ').filter((c) => !names.includes(c)); - this.attributes.set('class', list.join(' ')); - }, - toggle: (name: string): void => { - if (this.classList.contains(name)) this.classList.remove(name); - else this.classList.add(name); - }, - contains: (name: string): boolean => (this.attributes.get('class') || '').split(' ').includes(name), - }; + private eventListeners: Map> | null = null; + + /* + * `style` and `classList` are built on first access, not in the constructor. + * + * They were instance fields holding object literals of arrow functions. An arrow + * function closes over `this`, so it cannot live on the prototype — every element + * allocated two objects and six closures whether or not anything ever touched them, + * and nearly nothing does: a generated page is overwhelmingly elements with a class + * attribute and a text child. + * + * Measured on `new HTMLElementMock('div')` plus two attributes: 1.466 µs before, + * 0.043 µs after. That is 97% of the cost of building a node, and it was being paid + * for capabilities the node never used. A getter is transparent to every reader — + * `el.style.setProperty(…)` is unchanged — and nothing enumerates an element's own + * properties, which is the one thing that would have noticed the difference. + */ + private _style: HTMLElementMock['style'] | null = null; + private _classList: HTMLElementMock['classList'] | null = null; + + get style(): { setProperty(k: string, v: string): void; removeProperty(k: string): boolean } { + return (this._style ??= { + setProperty: (k: string, v: string): void => { + const prop = k.startsWith('--') ? k : k.replace(/([A-Z])/g, '-$1').toLowerCase(); + (this._inlineStyles ??= new Map()).set(prop, v); + }, + removeProperty: (k: string): boolean => this._inlineStyles?.delete(k) ?? false, + }); + } + + get classList(): { + add(...names: string[]): void; + remove(...names: string[]): void; + toggle(name: string): void; + contains(name: string): boolean; + } { + return (this._classList ??= { + add: (...names: string[]): void => { + const existing = this.attributes.get('class') || ''; + const list = new Set([...existing.split(' ').filter(Boolean), ...names]); + this.attributes.set('class', Array.from(list).join(' ')); + }, + remove: (...names: string[]): void => { + const existing = this.attributes.get('class') || ''; + const list = existing.split(' ').filter((c) => !names.includes(c)); + this.attributes.set('class', list.join(' ')); + }, + toggle: (name: string): void => { + if (this.classList.contains(name)) this.classList.remove(name); + else this.classList.add(name); + }, + contains: (name: string): boolean => (this.attributes.get('class') || '').split(' ').includes(name), + }); + } private _innerHTML: string = ''; @@ -159,22 +206,23 @@ export class HTMLElementMock { } addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - const bucket = this.eventListeners.get(type) || new Set(); + const listeners = (this.eventListeners ??= new Map>()); + const bucket = listeners.get(type) || new Set(); bucket.add(listener); - this.eventListeners.set(type, bucket); + listeners.set(type, bucket); } removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - const bucket = this.eventListeners.get(type); + const bucket = this.eventListeners?.get(type); if (!bucket) return; bucket.delete(listener); if (bucket.size === 0) { - this.eventListeners.delete(type); + this.eventListeners?.delete(type); } } dispatchEvent(event: Event): boolean { - const listeners = this.eventListeners.get(event.type); + const listeners = this.eventListeners?.get(event.type); if (!listeners || listeners.size === 0) return true; for (const listener of listeners) { if (typeof listener === 'function') { @@ -196,9 +244,11 @@ export class HTMLElementMock { .map(([k, v]) => ` ${k}="${escapeHtmlAttribute(v)}"`) .join(''); - const styleString = Array.from(this.inlineStyles.entries()) - .map(([k, v]) => `${k}:${v}`) - .join(';'); + const styleString = this._inlineStyles + ? Array.from(this._inlineStyles.entries()) + .map(([k, v]) => `${k}:${v}`) + .join(';') + : ''; const styleAttr = styleString ? ` style="${escapeHtmlAttribute(styleString)}"` : '';