From 40bd4287dfbb90516a0a57732a8a6e5ed5962b85 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 15:01:44 -0400 Subject: [PATCH 01/99] Restyle a :has() change's earlier siblings only for a :has(+ x) or :has(~ x) A change under an element restyled every element a :has() could be tested on: its ancestors, and every earlier sibling of each. Only a :has() whose argument starts with + or ~ can match from a sibling, so the siblings are walked only when a rule has one. In a list styled with li:has(.on), toggling a class in one item restyled every item before it. The box model and a list marker's text style are kept by the style object a resolved style keeps until it changes, as the paint style is. Co-Authored-By: Claude Opus 5.5 --- src/internal/cssom.ts | 94 ++++++++++++++++++++++++++++++++++++---- src/internal/layout.ts | 2 +- src/internal/painter.ts | 55 ++++++++++++++--------- tests/stylesheet.test.ts | 29 +++++++++++++ 4 files changed, 149 insertions(+), 31 deletions(-) diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index d8631e0c..8d4b500a 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -391,8 +391,34 @@ export function getCellBorderWidths( }; } -/** An element's margins, borders and padding, in cells. */ +// The entry point for every read of the cascade from a node, including +// reads deep inside the cascade itself that have no Cascade in +// hand. +const documentCascades = new WeakMap(); + +// Each resolved style's box model, by the object the style keeps until +// it changes. +const boxModels = new WeakMap(); + +/** + * An element's margins, borders and padding, in cells. The same object + * until the element's style changes, so a caller copies it to change it. + */ export function getBoxModel(element: Element): CSSValues.BoxModel { + const document = ownerDocumentOf(element); + const cascade = document ? documentCascades.get(document) : undefined; + const key = cascade?.getStyleKey(element) ?? null; + let model = key === null ? undefined : boxModels.get(key); + if (model === undefined) { + model = readBoxModel(element); + if (key !== null) { + boxModels.set(key, model); + } + } + return model; +} + +function readBoxModel(element: Element): CSSValues.BoxModel { // The engine's own read: the cascade's declaration directly, without // the author path's resolved-value work. const length = (property: string, vertical: boolean, signed = false) => @@ -443,11 +469,6 @@ export function getBoxModel(element: Element): CSSValues.BoxModel { }; } -// The entry point for every read of the cascade from a node, including -// reads deep inside the cascade itself that have no Cascade in -// hand. -const documentCascades = new WeakMap(); - // What list-style-type spells, quoted the way a content value is // written. Null outside a list. function getDefaultMarkerContent(hostElement: Element): string | null { @@ -5986,6 +6007,7 @@ interface ParsedCSSRule { // hasAnchors keeps them, and the pseudo-classes inside it. hasAnchors?: readonly string[]; hasStates?: readonly string[]; + hasSiblings?: boolean; // Cascade ORIGIN, the tier above specificity. Every author rule beats // every UA rule, which lets `input::placeholder { color }` beat the UA @@ -6221,6 +6243,9 @@ export class Cascade { // change inside an element that has none of them leaves every :has() // as it was. hasAnchors: Set; + // Whether a :has() argument starts with + or ~, which an element's + // earlier siblings can match from, and not only its ancestors. + hasSiblingRules: boolean; // The engine reads this to decide whether the terminal must report // pointer motion. A sheet that never tests :hover cannot show it, and @@ -6329,6 +6354,7 @@ export class Cascade { this.scopedRulesExist = false; this.hasRulesExist = false; this.hasAnchors = new Set(); + this.hasSiblingRules = false; this.hasStates = new Set(); this.hoverRulesDeclared = false; this.parsedStyleSheetCount = -1; @@ -6415,7 +6441,9 @@ export class Cascade { for ( let element: Element | null = ancestor; element !== null; - element = element.previousElementSibling + element = this.hasSiblingRules + ? element.previousElementSibling + : null ) { if (!anchors.has(element) && couldAnchorHas(this, element)) { anchors.add(element); @@ -6881,8 +6909,12 @@ function noteHasRule( cascade: Cascade, anchors: readonly string[], states: readonly string[], + siblings: boolean, ): void { cascade.hasRulesExist = true; + if (siblings) { + cascade.hasSiblingRules = true; + } for (const anchor of anchors) { cascade.hasAnchors.add(anchor); } @@ -6891,6 +6923,39 @@ function noteHasRule( } } +// Whether a :has() in the selector takes a relative selector that starts +// with + or ~. +function hasSiblingArgument(selector: string): boolean { + for ( + let at = selector.indexOf(":has("); + at !== -1; + at = selector.indexOf(":has(", at + 5) + ) { + let depth = 0; + let start = at + 5; + for (let index = start; index < selector.length; index++) { + const char = selector[index]; + if (char === "(" || char === "[") { + depth++; + } else if (char === ")" || char === "]") { + if (depth === 0) { + if (/^\s*[+~]/.test(selector.slice(start, index))) { + return true; + } + break; + } + depth--; + } else if (char === "," && depth === 0) { + if (/^\s*[+~]/.test(selector.slice(start, index))) { + return true; + } + start = index + 1; + } + } + } + return false; +} + // For each :has() in a selector, what the compound it is written on asks // of an element: its id, else a class, else its local name, else // anything. What a functional pseudo-class or an attribute selector asks @@ -6965,10 +7030,16 @@ function forgetShadowRoot(cascade: Cascade, root: ShadowRoot): void { // The root's :has() rules went with it, and so does what they asked for. cascade.hasRulesExist = false; cascade.hasAnchors = new Set(); + cascade.hasSiblingRules = false; cascade.hasStates = new Set(); for (const rule of cascade.parsedRules) { if (rule.hasAnchors !== undefined) { - noteHasRule(cascade, rule.hasAnchors, rule.hasStates ?? []); + noteHasRule( + cascade, + rule.hasAnchors, + rule.hasStates ?? [], + rule.hasSiblings === true, + ); } } // A parse already due builds without the root. One that is not would @@ -8606,6 +8677,7 @@ function parseStylesheetsNow(cascade: Cascade): void { cascade.scopedRulesExist = false; cascade.hasRulesExist = false; cascade.hasAnchors = new Set(); + cascade.hasSiblingRules = false; cascade.hasStates = new Set(); cascade.hoverRulesDeclared = false; cascade.stylesheetsDirty = false; @@ -9543,13 +9615,15 @@ function parseSelector( // sweep only for documents that need it. let hasAnchors: string[] | undefined; let hasStates: string[] | undefined; + let hasSiblings: boolean | undefined; if (selector.includes(":has(")) { hasAnchors = getHasAnchors(selector); const inside = selector.slice(selector.indexOf(":has(") + 5); hasStates = Array.from(inside.matchAll(/:([a-zA-Z-]+)/g), (match) => match[1].toLowerCase(), ); - noteHasRule(cascade, hasAnchors, hasStates); + hasSiblings = hasSiblingArgument(selector); + noteHasRule(cascade, hasAnchors, hasStates, hasSiblings); } if (selector.includes(":hover")) { cascade.hoverRulesDeclared = true; @@ -9637,6 +9711,7 @@ function parseSelector( reachesHost, hasAnchors, hasStates, + hasSiblings, layer, layerRank: 0, scopes, @@ -9662,6 +9737,7 @@ function parseSelector( reachesHost, hasAnchors, hasStates, + hasSiblings, layer, layerRank: 0, scopes, diff --git a/src/internal/layout.ts b/src/internal/layout.ts index 33bb0b53..bbc55d5c 100644 --- a/src/internal/layout.ts +++ b/src/internal/layout.ts @@ -3445,7 +3445,7 @@ function collectLeaves( syncIndependentFormattingContext(layout, element); } - const boxModel = getBoxModel(element); + const boxModel = {...getBoxModel(element)}; // getBoxModel carries only absolute widths. A percentage // resolves here against the run's available width, which is diff --git a/src/internal/painter.ts b/src/internal/painter.ts index 11a4d68e..c17d63e5 100644 --- a/src/internal/painter.ts +++ b/src/internal/painter.ts @@ -1641,27 +1641,7 @@ function renderOutsideMarker( // Cells, not code units. "日本 " is 3 characters and 5 cells. const markerWidth = ctx.measureText(markerContent).width; - const markerColor = - getComputedValue(element, "color", "::marker") || - getComputedValue(element, "color"); - const {bold: markerBold, dim: markerDim} = resolveFontWeight( - getComputedValue(element, "font-weight", "::marker"), - ); - const markerItalic = - getComputedValue(element, "font-style", "::marker") === "italic"; - const markerUnderline = CSSValues.parseTextDecorationLine( - getComputedValue(element, "text-decoration-line", "::marker"), - ).underline; - - const markerTextStyle = { - fg: markerColor && markerColor !== "initial" - ? CSSValues.numberFromCSSColor(markerColor) - : undefined, - bold: markerBold, - dim: markerDim, - italic: markerItalic, - underline: markerUnderline, - }; + const markerTextStyle = getMarkerTextStyle(painter, element); // Outside the content box, as css-lists-3 §3.3 places it. A marker // that would start before the first column is clipped away whole, @@ -1676,6 +1656,39 @@ function renderOutsideMarker( ctx.drawText(markerContent, markerX, Math.round(rect.top), markerTextStyle); } +// A marker's text style, by the object its element's style keeps until it +// changes. +const markerTextStyles = new WeakMap(); + +function getMarkerTextStyle(painter: Painter, element: Element): CellStyle { + const key = painter.cascade.getStyleKey(element); + const known = key === null ? undefined : markerTextStyles.get(key); + if (known !== undefined) { + return known; + } + const color = + getComputedValue(element, "color", "::marker") || + getComputedValue(element, "color"); + const {bold, dim} = resolveFontWeight( + getComputedValue(element, "font-weight", "::marker"), + ); + const style: CellStyle = { + fg: color && color !== "initial" + ? CSSValues.numberFromCSSColor(color) + : undefined, + bold, + dim, + italic: getComputedValue(element, "font-style", "::marker") === "italic", + underline: CSSValues.parseTextDecorationLine( + getComputedValue(element, "text-decoration-line", "::marker"), + ).underline, + }; + if (key !== null) { + markerTextStyles.set(key, style); + } + return style; +} + // A focused editing host parks the cursor at the document selection's // focus, the way a focused text control parks it at its own, and shows // none while the selection holds text. diff --git a/tests/stylesheet.test.ts b/tests/stylesheet.test.ts index a8c39a12..372fb288 100644 --- a/tests/stylesheet.test.ts +++ b/tests/stylesheet.test.ts @@ -1,6 +1,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; +import {getBoxModel} from "../src/internal/cssom.ts"; import { getPseudoHost, getPseudoName, @@ -622,3 +623,31 @@ test(":has() restyles its anchor, its siblings and what it styles below when a c expect(color(".title")).toBe("rgb(1, 2, 3)"); termdom.dispose(); }); + +test("a :has() that reads only down the tree restyles a changed item's ancestors, not the items before it", async () => { + const terminal = new MockProcess(); + const termdom = new TermDOM({transport: terminal.transport}); + const {document} = termdom; + document.body.innerHTML = + "
    " + + Array.from({length: 5}, (_, i) => `
  • ${i}
  • `) + .join("") + + "
"; + await nextFrame(termdom); + const items = [...document.querySelectorAll("li")]; + const before = items.map((item) => getBoxModel(item)); + document.getElementById("s3")!.classList.add("on"); + await nextFrame(termdom); + const after = items.map((item) => getBoxModel(item)); + // The box model is kept until its element restyles. + expect(after.map((model, i) => model === before[i])).toEqual([ + true, + true, + true, + false, + true, + ]); + expect(termdom.window.getComputedStyle(items[3]).color) + .toBe("rgb(255, 0, 0)"); + termdom.dispose(); +}); From 63aebc42db05266d87d1b824cfd0db8b1a285724 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 15:17:49 -0400 Subject: [PATCH 02/99] Test that the box model and marker style caches follow every kind of style change Co-Authored-By: Claude Opus 5.5 --- tests/style-caches.test.ts | 107 +++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/style-caches.test.ts diff --git a/tests/style-caches.test.ts b/tests/style-caches.test.ts new file mode 100644 index 00000000..450d5012 --- /dev/null +++ b/tests/style-caches.test.ts @@ -0,0 +1,107 @@ +import {expect, test} from "@b9g/libuild/test"; + +import {TermDOM} from "../src/index.ts"; +import {getBoxModel} from "../src/internal/cssom.ts"; +import {MockProcess, nextFrame} from "./test-utils.js"; + +async function mount(html: string): Promise<{dom: TermDOM; document: any}> { + const terminal = new MockProcess({cols: 40, rows: 12}); + const dom = new TermDOM({transport: terminal.transport}); + dom.document.body.innerHTML = html; + await nextFrame(dom); + return {dom, document: dom.document}; +} + +test("the box model is the same object until the element's style changes", async () => { + const {dom, document} = + await mount("
a
b
"); + const a = document.getElementById("a"); + const first = getBoxModel(a); + expect(getBoxModel(a)).toBe(first); + document.getElementById("b").style.color = "red"; + await nextFrame(dom); + expect(getBoxModel(a)).toBe(first); + dom.dispose(); +}); + +test("the box model follows an inline style change", async () => { + const {dom, document} = + await mount("
a
"); + const a = document.getElementById("a"); + expect(getBoxModel(a).paddingLeft).toBe(2); + a.style.paddingLeft = "5px"; + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft).toBe(5); + a.removeAttribute("style"); + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft === 0).toBe(true); + dom.dispose(); +}); + +test("the box model follows a class that changes which rule matches", async () => { + const {dom, document} = await mount( + "
a
", + ); + const a = document.getElementById("a"); + expect([getBoxModel(a).marginLeft, getBoxModel(a).borderLeftWidth]) + .toEqual([0, 0]); + a.classList.add("wide"); + await nextFrame(dom); + expect([getBoxModel(a).marginLeft, getBoxModel(a).borderLeftWidth]) + .toEqual([4, 1]); + a.classList.remove("wide"); + await nextFrame(dom); + expect([getBoxModel(a).marginLeft, getBoxModel(a).borderLeftWidth]) + .toEqual([0, 0]); + dom.dispose(); +}); + +test("the box model follows a stylesheet edited in place", async () => { + const {dom, document} = await mount( + "
a
", + ); + const a = document.getElementById("a"); + expect(getBoxModel(a).paddingLeft).toBe(1); + document.getElementById("s").textContent = "div { padding-left: 3px }"; + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft).toBe(3); + const sheet = document.getElementById("s").sheet; + sheet.cssRules[0].style.paddingLeft = "6px"; + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft).toBe(6); + dom.dispose(); +}); + +test("the box model follows an inherited font size its lengths are in", async () => { + const {dom, document} = await mount( + "
a
", + ); + const a = document.getElementById("a"); + expect(getBoxModel(a).paddingLeft).toBe(2); + document.getElementById("p").style.fontSize = "3px"; + await nextFrame(dom); + expect(getBoxModel(a).paddingLeft).toBe(3); + dom.dispose(); +}); + +test("a list marker's color follows its ::marker rule", async () => { + const {dom, document} = await mount( + "
  • item
", + ); + const bulletColor = (): string => { + const output = dom.renderANSI(); + const bullet = output.indexOf("•"); + return output.slice(0, bullet); + }; + expect(bulletColor()).toContain("38;2;0;128;0"); + document.getElementById("s").textContent = + "li::marker { color: rgb(255, 0, 0) }"; + await nextFrame(dom); + expect(bulletColor()).toContain("38;2;255;0;0"); + document.querySelector("li").style.fontWeight = "bold"; + document.getElementById("s").textContent = + "li::marker { color: rgb(0, 0, 255) }"; + await nextFrame(dom); + expect(bulletColor()).toContain("38;2;0;0;255"); + dom.dispose(); +}); From afca46ebb202d4bc98757e9a7cecab06427a675b Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 15:26:42 -0400 Subject: [PATCH 03/99] Split a text into graphemes once, and build each output line as a string A list marker is not ASCII, so every frame measured and drew each item's bullet through Intl.Segmenter. A short text's graphemes are now kept. The frame writer appended every cell to an array it then joined, and tested each one-character cell with a regex. Co-Authored-By: Claude Opus 5.5 --- src/internal/framebuffer.ts | 65 +++++++++++++++++-------------------- src/internal/text.ts | 14 ++++++++ 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/internal/framebuffer.ts b/src/internal/framebuffer.ts index 2cdc3308..bd0fcadf 100644 --- a/src/internal/framebuffer.ts +++ b/src/internal/framebuffer.ts @@ -1,8 +1,8 @@ import {blendColor, getColorAlpha, TRANSPARENCY_UNIT} from "./cssvalues.ts"; import type {ColorDepth, Exchange} from "./exchange.ts"; import { + getGraphemes, getStringWidth, - graphemeSegmenter, isControlByte, isWidthUncertain, PRINTABLE_ASCII, @@ -129,66 +129,61 @@ function getUnderlineCodes(style: UnderlineStyle): string[] { class FrameWriter { colorDepth: ColorDepth; - out: string[]; + out: string; constructor(colorDepth: ColorDepth) { this.colorDepth = colorDepth; - this.out = []; + this.out = ""; } - // Most takes hold one piece or none. take(): string { - const out = this.out; - if (out.length === 0) { - return ""; - } - const taken = out.length === 1 ? out[0] : out.join(""); - out.length = 0; + const taken = this.out; + this.out = ""; return taken; } carriageReturn(): this { - this.out.push("\r"); + this.out += "\r"; return this; } newLine(rows = 1): this { - this.out.push("\r\n".repeat(rows)); + this.out += "\r\n".repeat(rows); return this; } cursorTo(row: number, col: number): this { - this.out.push(`\x1b[${row};${col}H`); + this.out += `\x1b[${row};${col}H`; return this; } cursorForward(columns: number): this { - this.out.push(`\x1b[${columns}C`); + this.out += `\x1b[${columns}C`; return this; } cursorDown(rows: number): this { - this.out.push(`\x1b[${rows}B`); + this.out += `\x1b[${rows}B`; return this; } saveCursor(): this { - this.out.push("\x1b7"); + this.out += "\x1b7"; return this; } restoreCursor(): this { - this.out.push("\x1b8"); + this.out += "\x1b8"; return this; } eraseToLineEnd(): this { - this.out.push("\x1b[K"); + this.out += "\x1b[K"; return this; } eraseBelow(): this { - this.out.push("\x1b[J"); + this.out += "\x1b[J"; return this; } @@ -197,41 +192,43 @@ class FrameWriter { * measures cursor moves from the top margin. */ setScrollRegion(top: number, bottom: number): this { - this.out.push(`\x1b[?6l\x1b[${top};${bottom}r`); + this.out += `\x1b[?6l\x1b[${top};${bottom}r`; return this; } resetScrollRegion(): this { - this.out.push("\x1b[r"); + this.out += "\x1b[r"; return this; } deleteLines(count: number): this { - this.out.push(`\x1b[${count}M`); + this.out += `\x1b[${count}M`; return this; } insertLines(count: number): this { - this.out.push(`\x1b[${count}L`); + this.out += `\x1b[${count}L`; return this; } resetStyle(): this { - this.out.push("\x1b[0m"); + this.out += "\x1b[0m"; return this; } style(run: StyleSpan): this { const escape = createStyleEscape(run, this.colorDepth); - if (escape !== "") { - this.out.push(escape); - } + this.out += escape; return this; } text(glyphs: string): this { - if (PRINTABLE_ASCII.test(glyphs)) { - this.out.push(glyphs); + if ( + glyphs.length === 1 + ? glyphs >= " " && glyphs <= "~" + : PRINTABLE_ASCII.test(glyphs) + ) { + this.out += glyphs; return this; } let safe = ""; @@ -244,7 +241,7 @@ class FrameWriter { } safe += char; } - this.out.push(safe); + this.out += safe; return this; } } @@ -1204,8 +1201,8 @@ export class CellContext { return {width: text.length}; } let width = 0; - for (const segment of graphemeSegmenter.segment(text)) { - width += getStringWidth(segment.segment); + for (const grapheme of getGraphemes(text)) { + width += getStringWidth(grapheme); } return {width}; } @@ -1224,9 +1221,7 @@ export class CellContext { return; } - for (const segment of graphemeSegmenter.segment(text)) { - const char = segment.segment; - + for (const char of getGraphemes(text)) { // Never write a control char to a cell. A cell is a column, and a // control byte must be rejected before it takes one, or it survives // to the output as a raw escape byte (injection from untrusted diff --git a/src/internal/text.ts b/src/internal/text.ts index fee0f773..a04e785e 100644 --- a/src/internal/text.ts +++ b/src/internal/text.ts @@ -96,6 +96,20 @@ export const PRINTABLE_ASCII = /^[\x20-\x7e]*$/; // Bounded, so a stream of unique strings (a logger) cannot grow it. const widthCache = new LRUCache(2 ** 14); +const graphemeCache = new LRUCache(2 ** 12); + +// The same array for the same short text, so a caller must not change it. +export function getGraphemes(text: string): string[] { + let graphemes = graphemeCache.get(text); + if (graphemes === undefined) { + graphemes = Array.from(graphemeSegmenter.segment(text), (s) => s.segment); + if (text.length <= 256) { + graphemeCache.set(text, graphemes); + } + } + return graphemes; +} + // Advances the attached terminal reported that disagree with the tables // (Terminal.app advances ☀️ one cell and ⛅️ two, and no rule separates // them). Not a cache: a measurement of the one terminal, written once, From ad1efd2789e50bb21478dacd457e16b153df21ac Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 15:32:46 -0400 Subject: [PATCH 04/99] Look up a pseudo-element's rules by its type instead of filtering every rule Each list item's marker filtered the whole rule list for ::marker rules every frame. The rules are already filed by pseudo-element, and are now filed again in cascade order whenever the rule list is sorted. Co-Authored-By: Claude Opus 5.5 --- src/internal/cssom.ts | 23 ++++++++++++++++------- tests/style-caches.test.ts | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index 8d4b500a..ba9b9067 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -8726,7 +8726,8 @@ function parseStylesheetsNow(cascade: Cascade): void { } // Origin first (UA below every author rule, later wins), then layer, -// then specificity, then the order the rules were read in. +// then specificity, then the order the rules were read in. The +// pseudo-element rules are filed again in that order. function sortRulesForCascade(cascade: Cascade): void { const sourceOrder = new Map( cascade.parsedRules.map((rule, index) => [rule, index] as const), @@ -8743,6 +8744,18 @@ function sortRulesForCascade(cascade: Cascade): void { } return sourceOrder.get(a)! - sourceOrder.get(b)!; }); + cascade.pseudoRulesByType = new Map(); + for (const rule of cascade.parsedRules) { + if (rule.pseudoElement === undefined) { + continue; + } + const byType = cascade.pseudoRulesByType.get(rule.pseudoElement); + if (byType) { + byType.push(rule); + } else { + cascade.pseudoRulesByType.set(rule.pseudoElement, [rule]); + } + } } // Declare a layer and every layer its path nests inside. @@ -10089,12 +10102,8 @@ function computePseudoElementStyle( pseudoElement: string, ): Record { const pseudoRoot = element.getRootNode() as unknown as Node; - const matchingRules = cascade.parsedRules.filter((rule) => { - if (rule.pseudoElement !== pseudoElement) { - return false; - } - return isRuleMatch(element, rule, pseudoRoot); - }); + const matchingRules = (cascade.pseudoRulesByType.get(pseudoElement) ?? []) + .filter((rule) => isRuleMatch(element, rule, pseudoRoot)); // A pseudo-element's declarations are a flat record, not a per-property // cascade, so a flow-relative declaration fills BOTH names of its slot diff --git a/tests/style-caches.test.ts b/tests/style-caches.test.ts index 450d5012..90590219 100644 --- a/tests/style-caches.test.ts +++ b/tests/style-caches.test.ts @@ -1,7 +1,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; -import {getBoxModel} from "../src/internal/cssom.ts"; +import {getBoxModel, getDeclaredDisplay} from "../src/internal/cssom.ts"; import {MockProcess, nextFrame} from "./test-utils.js"; async function mount(html: string): Promise<{dom: TermDOM; document: any}> { @@ -84,6 +84,24 @@ test("the box model follows an inherited font size its lengths are in", async () dom.dispose(); }); +test("the declared display follows a class and an edited stylesheet", async () => { + const {dom, document} = await mount( + "a", + ); + const a = document.getElementById("a"); + expect(getDeclaredDisplay(a)).toBe("inline"); + a.className = "row"; + await nextFrame(dom); + expect(getDeclaredDisplay(a)).toBe("flex"); + document.getElementById("s").textContent = ".row { display: grid }"; + await nextFrame(dom); + expect(getDeclaredDisplay(a)).toBe("grid"); + a.removeAttribute("class"); + await nextFrame(dom); + expect(getDeclaredDisplay(a)).toBe("inline"); + dom.dispose(); +}); + test("a list marker's color follows its ::marker rule", async () => { const {dom, document} = await mount( "
  • item
", @@ -105,3 +123,18 @@ test("a list marker's color follows its ::marker rule", async () => { expect(bulletColor()).toContain("38;2;0;0;255"); dom.dispose(); }); + +test("a list marker's text follows a ::marker rule added after the first frame", async () => { + const {dom, document} = + await mount("
  • item
"); + expect(dom.renderANSI()).toContain("•"); + document.getElementById("s").textContent = "li::marker { content: '> ' }"; + await nextFrame(dom); + expect(dom.renderANSI()).toContain("> item"); + document.getElementById("s").textContent = + "ul li::marker { content: '- ' } li::marker { content: '* ' } " + + "@layer a { ul li::marker { content: '+ ' } }"; + await nextFrame(dom); + expect(dom.renderANSI()).toContain("- item"); + dom.dispose(); +}); From 9caa674f1d53b1a5c2c82e9179b8c74ae4a8398e Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 15:37:02 -0400 Subject: [PATCH 05/99] Drop an @import or @namespace out of its place, and read namespaces from the top of a sheet A sheet kept an @import or @namespace after a style rule, after a later kind of prelude rule, or nested in another rule. Browsers drop each of these, as css-cascade-5 and css-namespaces-3 require. With namespaces only at the top, each style rule's parse reads the sheet's namespaces from the top instead of walking every rule in the sheet. Co-Authored-By: Claude Opus 5.5 --- src/internal/cssom.ts | 36 +++++++++++++++++++++++++++++++++++- tests/cssom.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index ba9b9067..21dd9ab5 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -1697,6 +1697,27 @@ class CSSStyleRule extends CSSGroupingRule { } } +// A sheet opens with @layer statements, then @import rules, then +// @namespace rules, and either of the last two anywhere else is dropped, +// as css-cascade-5 and css-namespaces-3 place them. +const PRELUDE_LAYERS = 0; +const PRELUDE_IMPORTS = 1; +const PRELUDE_NAMESPACES = 2; +const PRELUDE_OVER = 3; + +function getPreludeStage(rule: CSSRule): number { + if (rule instanceof CSSLayerStatementRule) { + return PRELUDE_LAYERS; + } + if (rule instanceof CSSImportRule) { + return PRELUDE_IMPORTS; + } + if (rule instanceof CSSNamespaceRule) { + return PRELUDE_NAMESPACES; + } + return PRELUDE_OVER; +} + function getSheetNamespaces( sheet: CSSStyleSheet | null, ): SelectorNamespaces | undefined { @@ -1704,7 +1725,10 @@ function getSheetNamespaces( return undefined; } const namespaces: SelectorNamespaces = {default: null, prefixes: new Map()}; - for (const rule of Array.from(sheet.cssRules)) { + for (const rule of sheet.cssRules) { + if (getPreludeStage(rule) === PRELUDE_OVER) { + break; + } if (!(rule instanceof CSSNamespaceRule)) { continue; } @@ -3275,6 +3299,7 @@ function convertRules( run = []; } }; + let stage = PRELUDE_LAYERS; for (const node of nodes) { if (node.type === "Declaration") { if (nestedContext) { @@ -3287,6 +3312,15 @@ function convertRules( if (!rule) { continue; } + const ruleStage = getPreludeStage(rule); + if (ruleStage !== PRELUDE_LAYERS && ruleStage !== PRELUDE_OVER) { + if (parentRule !== null || ruleStage < stage) { + continue; + } + stage = ruleStage; + } else if (stage !== PRELUDE_LAYERS || ruleStage === PRELUDE_OVER) { + stage = PRELUDE_OVER; + } if (rule instanceof CSSNamespaceRule) { if (rule.prefix === "") { namespaces.default = rule.namespaceURI; diff --git a/tests/cssom.test.ts b/tests/cssom.test.ts index b81dfcbb..e34e89ad 100644 --- a/tests/cssom.test.ts +++ b/tests/cssom.test.ts @@ -547,3 +547,44 @@ test("text the CSS parsers cannot judge passes through as authored", () => { dom.dispose(); }); + +test("an @import or @namespace out of its place at the top of a sheet is dropped", () => { + const {dom} = makeDOM(); + const names = (text: string): string[] => { + const style = dom.document.createElement("style"); + style.textContent = text; + dom.document.head.append(style); + const rules = [...style.sheet!.cssRules].map((rule) => + rule.constructor.name); + style.remove(); + return rules; + }; + expect(names("a {} @import url(x.css); @namespace url(y); b {}")) + .toEqual(["CSSStyleRule", "CSSStyleRule"]); + expect(names("@namespace url(y); @import url(x.css);")) + .toEqual(["CSSNamespaceRule"]); + expect(names("@layer a; @import url(x.css); @namespace url(y);")) + .toEqual(["CSSLayerStatementRule", "CSSImportRule", "CSSNamespaceRule"]); + expect(names("@import url(x.css); @layer a; @namespace url(y);")) + .toEqual(["CSSImportRule", "CSSLayerStatementRule"]); + expect(names("@media all { @import url(x.css); @namespace url(y); }")) + .toEqual(["CSSMediaRule"]); + dom.dispose(); +}); + +test("a namespace prefix declared after a style rule does not apply", async () => { + const {dom} = makeDOM("

text

"); + const style = dom.document.createElement("style"); + style.textContent = + "@namespace h url(http://www.w3.org/1999/xhtml); h|p { color: red } " + + "b {} @namespace q url(http://www.w3.org/1999/xhtml); q|p { color: blue }"; + dom.document.head.append(style); + await nextFrame(dom); + expect( + dom.document.defaultView!.getComputedStyle( + dom.document.querySelector("p")!, + ).color, + ).toBe("rgb(255, 0, 0)"); + expect(style.sheet!.cssRules.length).toBe(3); + dom.dispose(); +}); From 8f4e028daef887facb5970625287f6ea45727773 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 15:41:28 -0400 Subject: [PATCH 06/99] Read each selector once, not once per document that parses it Every document parsed the UA sheet again, and reading its selectors with css-tree was most of what a new document's stylesheet parse cost. A reading depends only on the selector's text and nothing changes it, so readings are kept by that text. Co-Authored-By: Claude Opus 5.5 --- src/internal/cssvalues.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/internal/cssvalues.ts b/src/internal/cssvalues.ts index d4283b90..1d3ea277 100644 --- a/src/internal/cssvalues.ts +++ b/src/internal/cssvalues.ts @@ -4613,11 +4613,27 @@ function harvestRequiredKeys( // and :empty. const SIBLING_SELECTOR = /[+~]|:(?:nth-|first-|last-|only-|empty)/; +// Every document reads the UA sheet's selectors again, so each selector +// is read once and its reading shared. No caller changes a reading. +const selectorReadings = new Map(); + +export function readSelector(selector: string): SelectorReading { + let reading = selectorReadings.get(selector); + if (reading === undefined) { + reading = readSelectorText(selector); + if (selectorReadings.size > 4096) { + selectorReadings.clear(); + } + selectorReadings.set(selector, reading); + } + return reading; +} + // A selector this parser cannot read weighs nothing. The matcher reads // a wider grammar and may still accept it, and a rule whose weight // cannot be counted should lose a tie. It anchors to no type and names // no keys. -export function readSelector(selector: string): SelectorReading { +function readSelectorText(selector: string): SelectorReading { const reachesSiblings = SIBLING_SELECTOR.test(selector); let failed = false; let list: CSSTree.SelectorNode | null = null; From d5b7230e267131c56cd7526de56d859a7776e75a Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 15:43:27 -0400 Subject: [PATCH 07/99] Build a window's class record and parse its operations once, not per window Each new window spread every platform class into a fresh object two times, and parsed the Web IDL text of each Window operation after building a map of every interface class. Both depend on nothing a window has. Co-Authored-By: Claude Opus 5.5 --- src/internal/dom.ts | 7 +++++-- src/internal/webidl.ts | 30 +++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/internal/dom.ts b/src/internal/dom.ts index f214ccae..424c340c 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -39540,8 +39540,10 @@ function startPendingImages( // cycle through cssvalues, and a table built at module load would read // its classes before they exist when canvas.ts is the first module // imported. -function getWindowClasses(): Record { - return { +let windowClasses: Readonly> | null = null; + +function getWindowClasses(): Readonly> { + windowClasses ??= { ...platform, ...CSSOM_INTERFACES, CanvasGradient, @@ -39551,6 +39553,7 @@ function getWindowClasses(): Record { ImageData, Path2D, } as unknown as Record; + return windowClasses; } /** The names of the interfaces a window exposes. */ diff --git a/src/internal/webidl.ts b/src/internal/webidl.ts index 4688ccec..6cfc53bb 100644 --- a/src/internal/webidl.ts +++ b/src/internal/webidl.ts @@ -845,6 +845,27 @@ export function installInterfaces( return interfaceObjects; } +// Every window wraps the same operations, so each is parsed once. +const windowOperations = new Map(); + +function getWindowOperation(name: string): Member | null { + let member = windowOperations.get(name); + if (member === undefined) { + const text = DOM_INTERFACES.Window?.members.find(([key]) => key === name); + if (text === undefined || !text[1].startsWith("o")) { + member = null; + } else { + const classes = new Map(); + for (const [type, interfaceName] of interfaceNames) { + classes.set(interfaceName, type); + } + member = parseMember(text[1], classes); + } + windowOperations.set(name, member); + } + return member; +} + /** * `implementation` as the member `name` of `window`, with the checks * Window's operation of that name makes. Called without an object, as a @@ -855,15 +876,10 @@ export function windowMember( name: string, implementation: (...args: never[]) => unknown, ): (...args: unknown[]) => unknown { - const text = DOM_INTERFACES.Window?.members.find(([key]) => key === name); - if (text === undefined || !text[1].startsWith("o")) { + const member = getWindowOperation(name); + if (member === null) { return implementation as (...args: unknown[]) => unknown; } - const classes = new Map(); - for (const [type, interfaceName] of interfaceNames) { - classes.set(interfaceName, type); - } - const member = parseMember(text[1], classes); const wrapper = { [name](this: unknown, ...args: unknown[]): unknown { if (this != null && this !== window) { From c87eb4683a97a85d182945efc135ccf53b7868cf Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 16:19:22 -0400 Subject: [PATCH 08/99] Try only the rules an element's name allows, and skip pseudo-element rules outside shadow trees The rule index filed rules by id and class, so a plain span tried 152 rules, among them the UA's rules for inputs, dialogs and table cells. A rule whose subject names an element is now filed by that name too. Pseudo-element rules match through a shadow host, so outside a shadow tree they are rejected before the ::part() test. Checking a name for uppercase is a loop rather than a regex. Co-Authored-By: Claude Opus 5.5 --- src/internal/cssom.ts | 48 ++++++++++++++++++++++++++++--------------- src/internal/text.ts | 12 ++++++++--- tests/cssom.test.ts | 13 ++++++++++++ 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index 21dd9ab5..55911e97 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -9802,10 +9802,12 @@ interface RuleIndex { rules: ParsedCSSRule[]; length: number; keyed: Map; - unkeyed: ParsedCSSRule[]; + // The rest by the element name their subject requires, so a span never + // tries the rules for inputs and table cells. + tagged: Map; unkeyedPositions: number[]; - // Candidates by the id and classes that chose them, since elements - // alike in both, like an editor's lines, try the same rules. + // Candidates by the name, id and classes that chose them, since elements + // alike in those, like an editor's lines, try the same rules. candidates: Map; } @@ -9825,23 +9827,26 @@ function getRuleIndex(cascade: Cascade): RuleIndex { rules, length: rules.length, keyed: new Map(), - unkeyed: [], + tagged: new Map(), unkeyedPositions: [], candidates: new Map(), }; for (const [position, rule] of rules.entries()) { // A pseudo-element's rule matches through its host, and a :host rule // through the tree's host, so neither is filed under the element. - if ( - rule.subjectKey === undefined || rule.pseudoElement || rule.reachesHost - ) { - index.unkeyed.push(rule); + if (rule.pseudoElement || rule.reachesHost) { + index.unkeyedPositions.push(position); + continue; + } + const key = rule.subjectKey ?? rule.subjectTag; + if (key === undefined) { index.unkeyedPositions.push(position); continue; } - const bucket = index.keyed.get(rule.subjectKey); + const buckets = rule.subjectKey === undefined ? index.tagged : index.keyed; + const bucket = buckets.get(key); if (bucket === undefined) { - index.keyed.set(rule.subjectKey, [position]); + buckets.set(key, [position]); } else { bucket.push(position); } @@ -9874,10 +9879,8 @@ function getCandidateRules( const index = getRuleIndex(cascade); const id = getIdValue(element); const classes = getClassTokens(element); - if (id === null && classes.size === 0) { - return index.unkeyed; - } - let key = id === null ? "" : `#${id}`; + const tag = localNameOf(element); + let key = id === null ? tag : `${tag} #${id}`; for (const name of classes) { key += ` .${name}`; } @@ -9886,6 +9889,14 @@ function getCandidateRules( return candidates; } const positions = new Set(index.unkeyedPositions); + // A foreign element's name keeps its case, and a type selector may + // still match it lowercased, as isRuleMatch allows. + const lower = tag.toLowerCase(); + for (const name of lower === tag ? [tag] : [tag, lower]) { + for (const position of index.tagged.get(name) ?? []) { + positions.add(position); + } + } if (id !== null) { for (const position of index.keyed.get(`#${id}`) ?? []) { positions.add(position); @@ -9918,15 +9929,19 @@ function getMatchingRules(cascade: Cascade, element: Element): ParsedCSSRule[] { .filter(Boolean); const matched = getCandidateRules(cascade, element).filter((rule) => { if (rule.pseudoElement) { + if (shadowHost === null) { + return false; + } // ::part(name) matches the shadow's HOST, and its declarations // cascade onto the part element. This is the standard CSS Shadow // Parts crossing. A built-in control's parts are the UA's, as in // every browser, and a page reaches them only through the // pseudo-elements above. - const partArg = rule.pseudoElement.match(/^::part\((.+)\)$/); + const partArg = rule.pseudoElement.startsWith("::part(") + ? rule.pseudoElement.match(/^::part\((.+)\)$/) + : null; if (partArg) { return ( - shadowHost !== null && (rule.uaOrigin || !isUAShadowTree(root)) && splitOnASCIIWhitespace(partArg[1]) .every((name) => partNames.includes(name)) && @@ -9937,7 +9952,6 @@ function getMatchingRules(cascade: Cascade, element: Element): ParsedCSSRule[] { ); } return ( - shadowHost !== null && partPseudos.includes(rule.pseudoElement) && isRuleMatch(shadowHost, rule) ); diff --git a/src/internal/text.ts b/src/internal/text.ts index a04e785e..a9c15c07 100644 --- a/src/internal/text.ts +++ b/src/internal/text.ts @@ -27,11 +27,17 @@ export function splitOnASCIIWhitespace(value: string): string[] { return trimmed === "" ? [] : trimmed.split(/[\t\n\f\r ]+/); } -const ASCII_UPPER = /[A-Z]/; - // What HTML and CSS case-fold with. Never the locale. export function toASCIILowercase(value: string): string { - if (!ASCII_UPPER.test(value)) { + let upper = false; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code >= 65 && code <= 90) { + upper = true; + break; + } + } + if (!upper) { return value; } return value.replace(/[A-Z]/g, (character) => diff --git a/tests/cssom.test.ts b/tests/cssom.test.ts index e34e89ad..7ecc2f30 100644 --- a/tests/cssom.test.ts +++ b/tests/cssom.test.ts @@ -588,3 +588,16 @@ test("a namespace prefix declared after a style rule does not apply", async () = expect(style.sheet!.cssRules.length).toBe(3); dom.dispose(); }); + +test("a type selector matches its element through the rules filed by name", () => { + const {dom} = makeDOM( + "" + + "

text

", + ); + const view = dom.document.defaultView!; + const color = (selector: string): string => + view.getComputedStyle(dom.document.querySelector(selector)!).color; + expect(color("p")).toBe("rgb(255, 0, 0)"); + expect(color("#f")).toBe("rgb(0, 0, 255)"); + dom.dispose(); +}); From d1f9e87bb2df0ae06b45e0bcdee85880c6b94700 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 16:28:14 -0400 Subject: [PATCH 09/99] Derive no boxes for a mutation inside a tree that has left the document A child added to a tree that was then removed before the frame still had its boxes derived, and asking whether each was display: contents resolved a style for every node of it. Building 20,000 spans under a div and removing the div resolved 20,000 styles. A detached tree holds no boxes, and the record that brings it back derives it whole. Co-Authored-By: Claude Opus 5.5 --- src/internal/dom.ts | 4 ++-- src/internal/layout.ts | 10 ++++++++-- tests/layout-invalidation.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/internal/dom.ts b/src/internal/dom.ts index 424c340c..ef46c55b 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -16750,8 +16750,8 @@ export function isModalDialog(node: globalThis.Node): boolean { ); } -function isConnectedNode(node: Node): boolean { - return _(node).connected; +export function isConnectedNode(node: globalThis.Node): boolean { + return _(node as Node).connected; } class HTMLDirectoryElement extends HTMLElement { diff --git a/src/internal/layout.ts b/src/internal/layout.ts index bbc55d5c..40203a0e 100644 --- a/src/internal/layout.ts +++ b/src/internal/layout.ts @@ -34,6 +34,7 @@ import { getAddedNodes, getScrollOffset, getShadowRoot, + isConnectedNode, isDocumentElement, isInFlatTree, isModalDialog, @@ -6732,6 +6733,12 @@ function invalidateForRecord( measured.add(target); return; } + measured.add(target); + // A tree that has left the document holds no boxes. Inserted again, it + // is derived whole from the record that brings it back. + if (!isConnectedNode(target)) { + return; + } // The removed nodes are already detached, so the container is reached // through the target rather than through them. if (nodeTypeOf(target) === target.ELEMENT_NODE) { @@ -6739,8 +6746,7 @@ function invalidateForRecord( } // A member gaining or losing a descendant changes no box list (the // member is still there, holding more or less), so the box measuring it - // is notified directly. - measured.add(target); + // is notified directly, above. for (const node of getAddedNodes(record)) { invalidateSubtreeDerivation(layout, node); } diff --git a/tests/layout-invalidation.test.ts b/tests/layout-invalidation.test.ts index 4c5c93ff..e76ffd3e 100644 --- a/tests/layout-invalidation.test.ts +++ b/tests/layout-invalidation.test.ts @@ -852,3 +852,31 @@ test("many children of one container changed in one frame all repaint", async () } dom.dispose(); }); + +test("a subtree changed while out of the document lays out its changes when it returns", async () => { + const terminal = new MockProcess({cols: 40, rows: 10}); + const dom = new TermDOM({transport: terminal.transport}); + const {document} = dom; + document.body.innerHTML = + "

first

second

after

"; + await nextFrame(dom); + const box = document.getElementById("box")!; + box.remove(); + await nextFrame(dom); + expect(dom.renderANSI()).not.toContain("first"); + box.querySelector("#a")!.textContent = "changed and much longer"; + const span = document.createElement("span"); + span.textContent = "added"; + box.prepend(span); + box.lastElementChild!.remove(); + document.body.prepend(box); + await nextFrame(dom); + const lines = dom.renderANSI().split("\n").map((line) => + line.replace(/\x1b\[[0-9;:]*m/g, "").trimEnd()); + expect(lines.slice(0, 3)).toEqual([ + "added", + "changed and much longer", + "after", + ]); + dom.dispose(); +}); From 8e0c779f3b76eed4bdad492214f8ee5fcc11a293 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 16:31:35 -0400 Subject: [PATCH 10/99] Attach no pseudo-elements in a detached tree, and walk an added tree without a NodeList Each added element's descendants came from querySelectorAll, which built a NodeList and defined an index property per element. They are now walked directly. A detached tree's pseudo-elements and shadow roots wait for the record that inserts it, which visits the same elements. Co-Authored-By: Claude Opus 5.5 --- src/internal/cssom.ts | 31 ++++++++++++++++++++--------- tests/layout-invalidation.test.ts | 33 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index 55911e97..64753dc7 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -44,12 +44,14 @@ import { hasAttributeOf, HTML_NAMESPACE, isButtonInput, + isConnectedNode, isDocumentElement, isShadowRoot, isUAShadowTree, localNameOf, MATHML_NAMESPACE, namespaceOf, + nextInTree, nodeTypeOf, ownerDocumentOf, pseudoElement, @@ -6526,15 +6528,26 @@ export class Cascade { shouldSyncStylesheets = true; } } else { - invalidateElementCaches(this, element); - attachPseudoElementsToElement(this, element); - registerConnectedShadowRoots(this, element); - - const childElements = element.querySelectorAll("*"); - for (const childElement of childElements) { - invalidateElementCaches(this, childElement); - attachPseudoElementsToElement(this, childElement); - registerConnectedShadowRoots(this, childElement); + // A detached tree's pseudo-elements and shadow roots wait + // for the record that brings it into the document. Its + // styles still answer getComputedStyle. + const connected = isConnectedNode(element); + const elements: Element[] = []; + for ( + let next: DOMNode | null = element as unknown as DOMNode; + next !== null; + next = nextInTree(next, element as unknown as DOMNode) + ) { + if (nodeTypeOf(next) === Node.ELEMENT_NODE) { + elements.push(next as unknown as Element); + } + } + for (const each of elements) { + invalidateElementCaches(this, each); + if (connected) { + attachPseudoElementsToElement(this, each); + registerConnectedShadowRoots(this, each); + } } } } diff --git a/tests/layout-invalidation.test.ts b/tests/layout-invalidation.test.ts index e76ffd3e..a5413681 100644 --- a/tests/layout-invalidation.test.ts +++ b/tests/layout-invalidation.test.ts @@ -880,3 +880,36 @@ test("a subtree changed while out of the document lays out its changes when it r ]); dom.dispose(); }); + +test("pseudo-elements built while a tree is detached show once it is inserted", async () => { + const terminal = new MockProcess({cols: 40, rows: 10}); + const dom = new TermDOM({transport: terminal.transport}); + const {document} = dom; + document.body.innerHTML = + ""; + await nextFrame(dom); + const box = document.createElement("div"); + const list = document.createElement("ul"); + box.append(list); + const item = document.createElement("li"); + item.textContent = "item"; + list.append(item); + const tag = document.createElement("p"); + tag.className = "tag"; + tag.textContent = "tagged"; + box.append(tag); + const warm = document.createElement("div"); + warm.className = "warm"; + const moved = document.createElement("span"); + moved.textContent = "moved"; + box.append(moved); + warm.append(moved); + box.append(warm); + document.body.append(box); + await nextFrame(dom); + const text = terminal.getPlainText(); + expect(text).toContain("• item"); + expect(text).toContain("> tagged"); + expect(text).toContain("moved"); + dom.dispose(); +}); From 4c83342897a1ac6ab3da03ea3f7cc872159f8802 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 17:03:55 -0400 Subject: [PATCH 11/99] Restyle only the children a sibling rule could name when a child comes or goes The UA sheet's details > summary:first-of-type sets display and list styles, so in every document a sibling rule reached descendants, and a parent gaining a child restyled everything under it. Appending a message to a chat log restyled every earlier message. Each child is now checked against the sibling rules' keys, as it already was when no rule reached descendants, and only one they could name restyles with its subtree. Co-Authored-By: Claude Opus 5.5 --- src/internal/cssom.ts | 6 ++--- tests/style-caches.test.ts | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index 64753dc7..6d7ccdd6 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -8350,11 +8350,9 @@ function invalidateElement(cascade: Cascade, element: Element): void { // children match, and the element itself through :empty. Their // descendants only follow when a sheet tests siblings outside a subject // or inherits through one. +// Only a child a sibling-tested compound could name restyles, with its +// subtree when such a rule reaches descendants. function invalidateChildren(cascade: Cascade, element: Element): void { - if (getSiblingIndex(cascade, element)?.reachesDescendants) { - invalidateSubtree(cascade, element); - return; - } invalidateSibling(cascade, element); for (const child of element.children) { invalidateSibling(cascade, child); diff --git a/tests/style-caches.test.ts b/tests/style-caches.test.ts index 90590219..a90b2908 100644 --- a/tests/style-caches.test.ts +++ b/tests/style-caches.test.ts @@ -138,3 +138,51 @@ test("a list marker's text follows a ::marker rule added after the first frame", expect(dom.renderANSI()).toContain("- item"); dom.dispose(); }); + +test("appending a child leaves its earlier siblings' styles alone", async () => { + const {dom, document} = + await mount("
a
"); + const first = document.getElementById("first"); + const span = first.firstElementChild; + const boxes = [getBoxModel(first), getBoxModel(span)]; + const next = document.createElement("div"); + next.textContent = "b"; + document.getElementById("log").append(next); + await nextFrame(dom); + expect([getBoxModel(first), getBoxModel(span)]).toEqual(boxes); + expect(getBoxModel(first)).toBe(boxes[0]); + expect(getBoxModel(span)).toBe(boxes[1]); + dom.dispose(); +}); + +test("a sibling rule that reaches descendants follows a child placed first", async () => { + const {dom, document} = await mount( + "" + + "
  • a
", + ); + expect(getBoxModel(document.getElementById("sa")).paddingLeft).toBe(2); + const b = document.createElement("li"); + b.innerHTML = "b"; + document.querySelector("ul").prepend(b); + await nextFrame(dom); + expect(getBoxModel(document.getElementById("sb")).paddingLeft).toBe(2); + expect(getBoxModel(document.getElementById("sa")).paddingLeft === 0) + .toBe(true); + dom.dispose(); +}); + +test("only a details element's first summary is its marker", async () => { + const {dom, document} = + await mount("
oldbody
"); + const view = dom.window; + const display = (id: string): string => + view.getComputedStyle(document.getElementById(id)).display; + expect(display("old")).toBe("list-item"); + const summary = document.createElement("summary"); + summary.id = "new"; + summary.textContent = "new"; + document.querySelector("details").prepend(summary); + await nextFrame(dom); + expect([display("new"), display("old")]).toEqual(["list-item", "block"]); + dom.dispose(); +}); From 3be561de845002ef008d261ada82a9b450921fca Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 17:12:09 -0400 Subject: [PATCH 12/99] Ask whether a scroller is the root box only when it has scrolled Measuring a box walked its ancestors and asked each whether it was the html or body element, reading both through the window's bindings, before reading a scroll offset that was nearly always zero. CodeMirror measures on every keystroke. Co-Authored-By: Claude Opus 5.5 --- src/internal/layout.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/internal/layout.ts b/src/internal/layout.ts index 40203a0e..3e31608d 100644 --- a/src/internal/layout.ts +++ b/src/internal/layout.ts @@ -4792,10 +4792,14 @@ function getScrollShift( if (next === null) { break; } - const scroller = getScrollOwner(layout, next); - if (scroller !== null) { - x -= getScrollOffset(scroller, "left"); - y -= getScrollOffset(scroller, "top"); + const owner = next.owner as Node | undefined; + if (owner && nodeTypeOf(owner) === owner.ELEMENT_NODE) { + const left = getScrollOffset(owner as Element, "left"); + const top = getScrollOffset(owner as Element, "top"); + if ((left !== 0 || top !== 0) && !isRootBox(layout, owner as Element)) { + x -= left; + y -= top; + } } current = next; } From 191fcceb3b065e4445fb6cb8da7db447f9ee3fce Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 17:24:31 -0400 Subject: [PATCH 13/99] Add a benchmark harness, and compare each pull request against its base bench/run.ts runs each scenario in bench/scenarios in a process of its own and prints the median CPU time of each measurement. With --against it checks a ref out into a temporary worktree and alternates runs between the two checkouts. Four scenarios are shaped like apps: startup to a first frame, scrolling a long document, appending to a React chat log, and typing in CodeMirror. Four are synthetic: the email fixtures, a 1000-row table, a :has() toggle and building 20,000 spans. A workflow runs the comparison on every pull request and writes the table to the job summary. Shared runners are too noisy to fail on. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bench.yml | 32 ++++++++ bench/harness.ts | 78 +++++++++++++++++++ bench/run.ts | 145 ++++++++++++++++++++++++++++++++++++ bench/scenarios/chat.ts | 83 +++++++++++++++++++++ bench/scenarios/dom-ops.ts | 22 ++++++ bench/scenarios/editor.ts | 73 ++++++++++++++++++ bench/scenarios/emails.ts | 18 +++++ bench/scenarios/scroll.ts | 25 +++++++ bench/scenarios/startup.ts | 26 +++++++ bench/scenarios/table.ts | 16 ++++ bench/scenarios/toggle.ts | 24 ++++++ eslint.config.js | 2 +- package.json | 1 + tsconfig.json | 2 +- 14 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/bench.yml create mode 100644 bench/harness.ts create mode 100644 bench/run.ts create mode 100644 bench/scenarios/chat.ts create mode 100644 bench/scenarios/dom-ops.ts create mode 100644 bench/scenarios/editor.ts create mode 100644 bench/scenarios/emails.ts create mode 100644 bench/scenarios/scroll.ts create mode 100644 bench/scenarios/startup.ts create mode 100644 bench/scenarios/table.ts create mode 100644 bench/scenarios/toggle.ts diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 00000000..ee1f1171 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,32 @@ +name: Benchmarks + +on: + pull_request: + +# Shared runners are too noisy to fail a pull request on a number, so the +# comparison is a report in the job summary, not a check. +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/setup-node@v5 + with: + node-version: 26 + + - run: bun install --frozen-lockfile + + - name: Compare with the base branch + run: | + { + echo "## Benchmarks: median CPU time, base against this pull request" + echo + node bench/run.ts --runs 3 --against "origin/${{ github.base_ref }}" --markdown + } >> "$GITHUB_STEP_SUMMARY" diff --git a/bench/harness.ts b/bench/harness.ts new file mode 100644 index 00000000..1d9f0068 --- /dev/null +++ b/bench/harness.ts @@ -0,0 +1,78 @@ +/** + * What every scenario shares. A scenario runs against the checkout named + * by its first argument, so the runner can point the same scenario at two + * checkouts, and prints one JSON line per measurement. + */ +import {readFileSync} from "node:fs"; + +export const root = process.argv[2]; + +/** The bench's own checkout, which every input is read from. */ +export const benchRoot = new URL("..", import.meta.url).pathname; + +const index = await import(`${root}/src/index.ts`); +const utils = await import(`${root}/tests/test-utils.ts`); + +export const TermDOM = index.TermDOM; +export const installGlobals = index.installGlobals; +export const MockProcess = utils.MockProcess; +export const nextFrame: (dom: unknown) => Promise = utils.nextFrame; + +export function readBenchFile(path: string): string { + return readFileSync(`${benchRoot}${path}`, "utf8"); +} + +export async function mount( + cols = 100, + rows = 40, +): Promise<{proc: any; dom: any}> { + const proc = new MockProcess({cols, rows}); + const dom = new TermDOM({transport: proc.transport}); + dom.attach(); + await nextFrame(dom); + return {proc, dom}; +} + +/** + * The terminal becomes a sink once the page is up, so what is measured + * is the engine and not the emulator reading its output. + */ +export function silence(proc: any): void { + proc.stdout.write = (chunk: unknown, enc?: unknown, cb?: unknown) => { + const callback = typeof enc === "function" ? enc : cb; + if (typeof callback === "function") { + (callback as () => void)(); + } + return true; + }; +} + +/** CPU time, so the time a frame waits for its turn is not counted. */ +export function cpuMs(): number { + const {user, system} = process.cpuUsage(); + return (user + system) / 1000; +} + +export function report(label: string, times: number[]): void { + const sorted = [...times].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + console.log(JSON.stringify({label, median})); +} + +export async function measure( + label: string, + steps: number, + step: (i: number) => Promise | void, + warm = 10, +): Promise { + for (let i = 0; i < warm; i++) { + await step(i); + } + const times: number[] = []; + for (let i = warm; i < warm + steps; i++) { + const start = cpuMs(); + await step(i); + times.push(cpuMs() - start); + } + report(label, times); +} diff --git a/bench/run.ts b/bench/run.ts new file mode 100644 index 00000000..4562a2da --- /dev/null +++ b/bench/run.ts @@ -0,0 +1,145 @@ +/** + * Runs the scenarios in bench/scenarios, each in a process of its own, + * and prints the median CPU time of each measurement. + * + * node bench/run.ts [--runs N] [--only a,b] [--against REF] [--markdown] + * + * --against checks REF out into a temporary worktree and runs the same + * scenarios against it, alternating with this checkout, so both sides + * share whatever the machine was doing. + */ +import {spawnSync} from "node:child_process"; +import {mkdtempSync, readdirSync, rmSync, symlinkSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; + +const benchRoot = new URL("..", import.meta.url).pathname; +const scenarioDirectory = new URL("scenarios/", import.meta.url).pathname; + +function option(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +const runs = Number(option("--runs") ?? 3); +const only = option("--only")?.split(","); +const against = option("--against"); +const markdown = process.argv.includes("--markdown"); + +const scenarios = readdirSync(scenarioDirectory) + .filter((name) => name.endsWith(".ts")) + .map((name) => name.slice(0, -3)) + .filter((name) => only === undefined || only.includes(name)); + +function git(...args: string[]): void { + const result = spawnSync("git", args, {cwd: benchRoot, stdio: "inherit"}); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed`); + } +} + +function runScenario(name: string, root: string): Map { + const result = spawnSync( + process.execPath, + [...process.execArgv, join(scenarioDirectory, `${name}.ts`), root], + { + cwd: root, + encoding: "utf8", + timeout: 600_000, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const medians = new Map(); + for (const line of result.stdout.split("\n")) { + if (line.startsWith("{")) { + const {label, median} = JSON.parse(line); + medians.set(label, median); + } + } + if (medians.size === 0) { + console.error(`${name} on ${root} reported nothing:\n${result.stderr}`); + } + return medians; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +const roots: Array<{name: string; path: string}> = []; +let worktree: string | undefined; +if (against !== undefined) { + worktree = mkdtempSync(join(tmpdir(), "termdom-bench-")); + git("worktree", "add", "--detach", worktree, against); + symlinkSync(join(benchRoot, "node_modules"), join(worktree, "node_modules")); + roots.push({name: against, path: worktree}); +} +roots.push({name: "this checkout", path: benchRoot.replace(/\/$/, "")}); + +const samples = new Map>(); +try { + for (let run = 0; run < runs; run++) { + for (const scenario of scenarios) { + for (const root of roots) { + for (const [label, value] of runScenario(scenario, root.path)) { + let byRoot = samples.get(label); + if (byRoot === undefined) { + byRoot = new Map(); + samples.set(label, byRoot); + } + const values = byRoot.get(root.name) ?? []; + values.push(value); + byRoot.set(root.name, values); + } + } + } + console.error(`run ${run + 1} of ${runs} done`); + } +} finally { + if (worktree !== undefined) { + rmSync(join(worktree, "node_modules")); + git("worktree", "remove", "--force", worktree); + } +} + +const header = [ + "Measurement", + ...roots.map((root) => `${root.name} (ms)`), + ...(roots.length === 2 ? ["Change"] : []), +]; +const rows = [...samples].map(([label, byRoot]) => { + const values = roots.map((root) => { + const values = byRoot.get(root.name); + return values === undefined ? NaN : median(values); + }); + const cells = [label, ...values.map((value) => value.toFixed(2))]; + if (values.length === 2) { + const change = (values[1] - values[0]) / values[0] * 100; + cells.push(`${change > 0 ? "+" : ""}${change.toFixed(0)}%`); + } + return cells; +}); + +if (markdown) { + console.log(`| ${header.join(" | ")} |`); + console.log( + `|${header.map((_, i) => i === 0 ? " --- " : " ---: ").join("|")}|`, + ); + for (const row of rows) { + console.log(`| ${row.join(" | ")} |`); + } +} else { + const widths = header.map((_, i) => + Math.max(header[i].length, ...rows.map((row) => row[i].length))); + const format = (cells: string[]): string => + cells + .map((cell, i) => + i === 0 ? cell.padEnd(widths[i]) : cell.padStart(widths[i]), + ) + .join(" "); + console.log(format(header)); + for (const row of rows) { + console.log(format(row)); + } +} diff --git a/bench/scenarios/chat.ts b/bench/scenarios/chat.ts new file mode 100644 index 00000000..7e2307a5 --- /dev/null +++ b/bench/scenarios/chat.ts @@ -0,0 +1,83 @@ +import { + installGlobals, + measure, + mount, + nextFrame, + silence, +} from "../harness.ts"; + +const {proc, dom} = await mount(); +installGlobals(dom); +const React = await import("react"); +const {createRoot} = await import("react-dom/client"); +const {flushSync} = await import("react-dom"); +const h = React.createElement; + +dom.document.head.innerHTML = ``; + +interface Message { + id: number; + who: string; + text: string; +} + +let append: (message: Message) => void = () => {}; + +function App(): React.ReactElement { + const [messages, setMessages] = React.useState([]); + append = (message) => setMessages((list) => [...list, message]); + return h( + "div", + null, + h( + "div", + {className: "log"}, + messages.map((message) => + h( + "div", + {key: message.id, className: `msg ${message.who}`}, + h("span", {className: "who"}, message.who === "you" ? "You" : "Bot"), + " ", + h( + "span", + {className: "time"}, + `12:${String(message.id % 60).padStart(2, "0")}`, + ), + h("span", {className: "body"}, message.text), + )), + ), + h( + "div", + {className: "prompt"}, + h("span", null, "› "), + h("textarea", {rows: 1}), + ), + ); +} + +const reactRoot = createRoot(dom.document.body); +flushSync(() => reactRoot.render(h(App))); +await nextFrame(dom); +silence(proc); + +const words = ( + "the quick brown fox jumps over the lazy dog while a terminal renders the dom" +).split(" "); +let id = 0; +await measure("react chat: append a message", 200, async () => { + const text = Array.from({length: 12 + (id % 20)}, (_, k) => words[(id + k) % + words.length]).join(" "); + flushSync(() => append({id, who: id % 2 ? "bot" : "you", text})); + id++; + await nextFrame(dom); +}); +dom.dispose(); diff --git a/bench/scenarios/dom-ops.ts b/bench/scenarios/dom-ops.ts new file mode 100644 index 00000000..50c3232c --- /dev/null +++ b/bench/scenarios/dom-ops.ts @@ -0,0 +1,22 @@ +import {measure, TermDOM} from "../harness.ts"; + +const term = new TermDOM(); +const {document} = term; +await measure( + "dom: build, render and remove 20000 spans", + 15, + () => { + const root = document.createElement("div"); + document.body.appendChild(root); + for (let k = 0; k < 20000; k++) { + const span = document.createElement("span"); + span.className = `c${k % 7}`; + span.textContent = "t"; + root.appendChild(span); + } + root.remove(); + term.renderANSI(); + }, + 3, +); +term.dispose(); diff --git a/bench/scenarios/editor.ts b/bench/scenarios/editor.ts new file mode 100644 index 00000000..02039066 --- /dev/null +++ b/bench/scenarios/editor.ts @@ -0,0 +1,73 @@ +import { + installGlobals, + measure, + mount, + nextFrame, + silence, +} from "../harness.ts"; + +const {proc, dom} = await mount(); +installGlobals(dom); +const {EditorState} = await import("@codemirror/state"); +const {EditorView, keymap, lineNumbers} = await import("@codemirror/view"); +const {defaultKeymap, history, historyKeymap} = await import( + "@codemirror/commands", +); +const {javascript} = await import("@codemirror/lang-javascript"); +const {defaultHighlightStyle, syntaxHighlighting} = await import( + "@codemirror/language", +); + +const {document} = dom; +document.body.innerHTML = "

Editor

"; +const doc = Array.from( + {length: 100}, + (_, i) => + [ + `function step${i}(value: number, rect: {x: number; y: number}): number {`, + `\tconst scaled = value * ${i + 1} + rect.x; // step ${i}`, + "\treturn Math.max(scaled, rect.y);", + "}", + ].join("\n"), +).join("\n"); +const view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [ + lineNumbers(), + history(), + javascript({typescript: true}), + syntaxHighlighting(defaultHighlightStyle), + keymap.of([...defaultKeymap, ...historyKeymap]), + EditorView.theme({ + ".cm-scroller": {lineHeight: "1"}, + ".cm-content": {padding: "0"}, + }), + ], + }), + parent: document.getElementById("editor"), +}); +view.focus(); +view.dispatch({selection: {anchor: doc.indexOf("\n", 200)}}); +await nextFrame(dom); +silence(proc); + +async function press(data: string): Promise { + const before = view.state.doc.length; + proc.stdin.simulateResponse(data); + for (let i = 0; i < 200 && view.state.doc.length === before; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } + await nextFrame(dom); +} + +const text = "const value = compute(element, rect);"; +let typed = 0; +await measure("codemirror: type a character", 120, async () => { + await press(text[typed % text.length]); + typed++; + if (typed % text.length === 0) { + await press("\r"); + } +}); +dom.dispose(); diff --git a/bench/scenarios/emails.ts b/bench/scenarios/emails.ts new file mode 100644 index 00000000..fe0ac74f --- /dev/null +++ b/bench/scenarios/emails.ts @@ -0,0 +1,18 @@ +import {readdirSync} from "node:fs"; + +import {benchRoot, measure, readBenchFile, TermDOM} from "../harness.ts"; + +const directory = "tests/fixtures/email"; +const emails = readdirSync(`${benchRoot}${directory}`) + .filter((name) => name.endsWith(".html")) + .map((name) => readBenchFile(`${directory}/${name}`)); +await measure( + "emails: parse and render every fixture", + 7, + () => { + for (const html of emails) { + new TermDOM({html}).renderANSI(); + } + }, + 2, +); diff --git a/bench/scenarios/scroll.ts b/bench/scenarios/scroll.ts new file mode 100644 index 00000000..13932e8b --- /dev/null +++ b/bench/scenarios/scroll.ts @@ -0,0 +1,25 @@ +import {marked} from "marked"; + +import {measure, mount, nextFrame, readBenchFile, silence} from "../harness.ts"; + +const html = marked.parse(readBenchFile("README.md"), {async: false}); +const {proc, dom} = await mount(); +dom.document.body.innerHTML = `
${html}
`; +await nextFrame(dom); +silence(proc); +const view = dom.window; +let direction = 1; + +async function scroll(rows: number): Promise { + const max = dom.document.body.scrollHeight - view.innerHeight; + const next = view.scrollY + rows * direction; + if (next > max || next < 0) { + direction = -direction; + } + view.scrollBy(0, rows * direction); + await nextFrame(dom); +} + +await measure("scroll: one row", 100, () => scroll(1)); +await measure("scroll: one page", 60, () => scroll(40)); +dom.dispose(); diff --git a/bench/scenarios/startup.ts b/bench/scenarios/startup.ts new file mode 100644 index 00000000..db087fc0 --- /dev/null +++ b/bench/scenarios/startup.ts @@ -0,0 +1,26 @@ +import {marked} from "marked"; + +import { + cpuMs, + MockProcess, + nextFrame, + readBenchFile, + report, + TermDOM, +} from "../harness.ts"; + +const html = marked.parse(readBenchFile("README.md"), {async: false}); +const times: number[] = []; +for (let i = 0; i < 25; i++) { + const proc = new MockProcess({cols: 100, rows: 40}); + const start = cpuMs(); + const dom = new TermDOM({transport: proc.transport}); + dom.document.body.innerHTML = `
${html}
`; + dom.attach(); + await nextFrame(dom); + if (i >= 5) { + times.push(cpuMs() - start); + } + dom.dispose(); +} +report("startup: README to first frame", times); diff --git a/bench/scenarios/table.ts b/bench/scenarios/table.ts new file mode 100644 index 00000000..b8a70449 --- /dev/null +++ b/bench/scenarios/table.ts @@ -0,0 +1,16 @@ +import {measure, TermDOM} from "../harness.ts"; + +const rows = Array.from( + {length: 1000}, + (_, i) => `${i}row ${i}x`, +).join(""); +const html = + `${rows}
`; +await measure( + "table: render 1000 rows", + 7, + () => { + new TermDOM({html}).renderANSI(); + }, + 2, +); diff --git a/bench/scenarios/toggle.ts b/bench/scenarios/toggle.ts new file mode 100644 index 00000000..30fa1c1b --- /dev/null +++ b/bench/scenarios/toggle.ts @@ -0,0 +1,24 @@ +import {measure, TermDOM} from "../harness.ts"; + +const term = new TermDOM(); +const {document} = term; +document.body.innerHTML = + "" + + "
    " + + Array.from( + {length: 500}, + (_, i) => `
  • item ${i}
  • `, + ).join("") + + "
"; +term.renderANSI(); +let next = 0; +await measure( + "toggle: a class under li:has(), 500 items", + 200, + () => { + document.getElementById(`s${next++ % 500}`).classList.toggle("on"); + term.renderANSI(); + }, + 50, +); +term.dispose(); diff --git a/eslint.config.js b/eslint.config.js index fad21ad7..ec170b67 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,7 +20,7 @@ export default [ { // Scripts and examples print to the terminal as their job. URL is a // runtime global everywhere the scripts run. - files: ["scripts/**", "examples/**"], + files: ["scripts/**", "examples/**", "bench/**"], languageOptions: {globals: {URL: "readonly"}}, rules: {"no-console": "off"}, }, diff --git a/package.json b/package.json index 38f4d476..e53fc8f9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "LICENSE" ], "scripts": { + "bench": "node bench/run.ts", "build": "libuild build", "lint": "eslint .", "prepublishOnly": "echo 'ERROR: Cannot publish from root directory. Use libuild to publish or stage the package.' && exit 1", diff --git a/tsconfig.json b/tsconfig.json index 98c2ac55..5677d7be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true }, - "include": ["src", "tests", "fuzz", "examples", "scripts"], + "include": ["src", "tests", "fuzz", "examples", "scripts", "bench"], // The drift ledger instantiates every class against lib.dom, which is // twenty seconds and a gigabyte the ordinary typecheck does not need. // scripts/lib-dom-drift.ts checks it in a program of its own. From c9702fb666fef033cff2c0fdb4c21c137ccd0c55 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 17:30:07 -0400 Subject: [PATCH 14/99] Make the canvas contexts' internal steps plain functions The symbol-keyed methods were moved off the public prototypes onto the records at startup, and called through those symbols. Each is now a module function that takes the record, as the engine's other reads do. The gradient and pattern lookups, the one pair a call chose between, are chosen by the paint's class. Co-Authored-By: Claude Opus 5.5 --- src/internal/canvas.ts | 837 ++++++++++++++++++++--------------------- 1 file changed, 401 insertions(+), 436 deletions(-) diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts index 68c39233..df48df81 100644 --- a/src/internal/canvas.ts +++ b/src/internal/canvas.ts @@ -42,7 +42,6 @@ import { type internals, seal, type Slots, - type Step, } from "./slots.ts"; import {getStringWidth, graphemeSegmenter, toVisualOrder} from "./text.ts"; @@ -156,8 +155,6 @@ export class ImageData { declareSealedRoot(ImageData); -const kColorAt = Symbol("colorAt"); - export interface ImageBitmapSlots { bitmapData: Bitmap | null; } @@ -436,8 +433,6 @@ interface Subpath { closed: boolean; } -const kSubpaths = Symbol("subpaths"); - class PathBuilder { subpaths: Subpath[]; last: [number, number] | null; @@ -786,7 +781,6 @@ interface DOMPointInit { export interface Path2DSlots { builder: PathBuilder; - [kSubpaths]: Step; } export interface Path2D { @@ -805,10 +799,6 @@ export class Path2D { } } - get [kSubpaths](): Subpath[] { - return (this as unknown as Slots).builder.subpaths; - } - addPath(path: Path2D, transform?: Partial): void { const m: Matrix = [ transform?.a ?? 1, @@ -1457,7 +1447,6 @@ function pathPolygons(subpaths: Subpath[]): number[][] { export interface CanvasGradientSlots { stops: Array<{offset: number; color: RGBA}>; position: (x: number, y: number) => number; - [kColorAt]: Step; } export interface CanvasGradient { @@ -1488,44 +1477,47 @@ export class CanvasGradient { } slots.stops.splice(index, 0, {offset, color: parsed}); } +} - /** The color at a user-space point, or null where the gradient has none. */ - [kColorAt](this: Slots, x: number, y: number): RGBA | null { - const slots = this; - const stops = slots.stops; - if (stops.length === 0) { - return {r: 0, g: 0, b: 0, a: 0}; - } - const t = slots.position(x, y); - if (Number.isNaN(t)) { - return null; - } - if (t <= stops[0].offset) { - return stops[0].color; - } - const last = stops[stops.length - 1]; - if (t >= last.offset) { - return last.color; - } - for (let i = 1; i < stops.length; i++) { - const a = stops[i - 1]; - const b = stops[i]; - if (t <= b.offset) { - const span = b.offset - a.offset; - const u = span === 0 ? 1 : (t - a.offset) / span; - // HTML interpolates the four channels as they are, without - // premultiplying. - const mix = (p: number, q: number) => p + (q - p) * u; - return { - r: mix(a.color.r, b.color.r), - g: mix(a.color.g, b.color.g), - b: mix(a.color.b, b.color.b), - a: mix(a.color.a, b.color.a), - }; - } - } +/** The color at a user-space point, or null where the gradient has none. */ +function getGradientColor( + slots: Slots, + x: number, + y: number, +): RGBA | null { + const stops = slots.stops; + if (stops.length === 0) { + return {r: 0, g: 0, b: 0, a: 0}; + } + const t = slots.position(x, y); + if (Number.isNaN(t)) { + return null; + } + if (t <= stops[0].offset) { + return stops[0].color; + } + const last = stops[stops.length - 1]; + if (t >= last.offset) { return last.color; } + for (let i = 1; i < stops.length; i++) { + const a = stops[i - 1]; + const b = stops[i]; + if (t <= b.offset) { + const span = b.offset - a.offset; + const u = span === 0 ? 1 : (t - a.offset) / span; + // HTML interpolates the four channels as they are, without + // premultiplying. + const mix = (p: number, q: number) => p + (q - p) * u; + return { + r: mix(a.color.r, b.color.r), + g: mix(a.color.g, b.color.g), + b: mix(a.color.b, b.color.b), + a: mix(a.color.a, b.color.a), + }; + } + } + return last.color; } declareSealedRoot(CanvasGradient); @@ -1535,7 +1527,6 @@ export interface CanvasPatternSlots { repeatX: boolean; repeatY: boolean; patternTransform: Matrix; - [kColorAt]: Step; } export interface CanvasPattern { @@ -1564,35 +1555,43 @@ export class CanvasPattern { transform?.f ?? 0, ]; } +} - [kColorAt](this: Slots, x: number, y: number): RGBA { - const slots = this; - const inverse = invert(slots.patternTransform); - if (inverse !== null) { - [x, y] = apply(inverse, x, y); - } - const bitmap = slots.patternBitmap; - const {width, height, data} = bitmap; - // The pattern tiles at the image's natural size. - let px = Math.floor((x * width) / naturalWidthOf(bitmap)); - let py = Math.floor((y * height) / naturalHeightOf(bitmap)); - if (slots.repeatX) { - px = ((px % width) + width) % width; - } - if (slots.repeatY) { - py = ((py % height) + height) % height; - } - if (px < 0 || py < 0 || px >= width || py >= height) { - return {r: 0, g: 0, b: 0, a: 0}; - } - const at = (py * width + px) * 4; - return { - r: data[at], - g: data[at + 1], - b: data[at + 2], - a: data[at + 3] / 255, - }; +function getPatternColor( + slots: Slots, + x: number, + y: number, +): RGBA { + const inverse = invert(slots.patternTransform); + if (inverse !== null) { + [x, y] = apply(inverse, x, y); + } + const bitmap = slots.patternBitmap; + const {width, height, data} = bitmap; + // The pattern tiles at the image's natural size. + let px = Math.floor((x * width) / naturalWidthOf(bitmap)); + let py = Math.floor((y * height) / naturalHeightOf(bitmap)); + if (slots.repeatX) { + px = ((px % width) + width) % width; } + if (slots.repeatY) { + py = ((py % height) + height) % height; + } + if (px < 0 || py < 0 || px >= width || py >= height) { + return {r: 0, g: 0, b: 0, a: 0}; + } + const at = (py * width + px) * 4; + return {r: data[at], g: data[at + 1], b: data[at + 2], a: data[at + 3] / 255}; +} + +function getPaintColor( + paint: CanvasGradient | CanvasPattern, + x: number, + y: number, +): RGBA | null { + return paint instanceof CanvasGradient + ? getGradientColor(_(paint), x, y) + : getPatternColor(_(paint), x, y); } declareSealedRoot(CanvasPattern); @@ -1785,17 +1784,6 @@ interface CanvasHost { direction(): "ltr" | "rtl"; } -const kSetMatrix = Symbol("setMatrix"); -const kSubpathsFor = Symbol("subpathsFor"); -const kReadPathArgs = Symbol("readPathArgs"); -const kStrokeStyle = Symbol("strokeStyle"); -const kScale = Symbol("scale"); -const kRectMask = Symbol("rectMask"); -const kPaint = Symbol("paint"); -const kDropText = Symbol("dropText"); -const kCoverText = Symbol("coverText"); -const kDrawText = Symbol("drawText"); - // The host getContext() is making a context for. A context made any other // way has none, and its constructor is illegal, as a browser's is. let constructingHost: CanvasHost | GridHost | null = null; @@ -1827,16 +1815,6 @@ export interface CanvasRenderingContext2DSlots { state: State; stack: State[]; path: PathBuilder; - [kSetMatrix]: Step; - [kSubpathsFor]: Step; - [kReadPathArgs]: Step; - [kStrokeStyle]: Step; - [kScale]: Step; - [kRectMask]: Step; - [kPaint]: Step; - [kDropText]: Step; - [kCoverText]: Step; - [kDrawText]: Step; } export interface CanvasRenderingContext2D { @@ -2102,21 +2080,22 @@ export class CanvasRenderingContext2D { scale(x: number, y: number): void { const slots = _(this); - slots[kSetMatrix](multiply(slots.state.transform, [x, 0, 0, y, 0, 0])); + setMatrix(slots, multiply(slots.state.transform, [x, 0, 0, y, 0, 0])); } rotate(angle: number): void { const cos = Math.cos(angle); const sin = Math.sin(angle); const slots = _(this); - slots[kSetMatrix]( + setMatrix( + slots, multiply(slots.state.transform, [cos, sin, -sin, cos, 0, 0]), ); } translate(x: number, y: number): void { const slots = _(this); - slots[kSetMatrix](multiply(slots.state.transform, [1, 0, 0, 1, x, y])); + setMatrix(slots, multiply(slots.state.transform, [1, 0, 0, 1, x, y])); } transform( @@ -2128,7 +2107,7 @@ export class CanvasRenderingContext2D { f: number, ): void { const slots = _(this); - slots[kSetMatrix](multiply(slots.state.transform, [a, b, c, d, e, f])); + setMatrix(slots, multiply(slots.state.transform, [a, b, c, d, e, f])); } setTransform( @@ -2141,9 +2120,9 @@ export class CanvasRenderingContext2D { ): void { const slots = _(this); if (typeof a === "number") { - slots[kSetMatrix]([a, b!, c!, d!, e!, f!]); + setMatrix(slots, [a, b!, c!, d!, e!, f!]); } else { - slots[kSetMatrix]([ + setMatrix(slots, [ a?.a ?? 1, a?.b ?? 0, a?.c ?? 0, @@ -2155,7 +2134,7 @@ export class CanvasRenderingContext2D { } resetTransform(): void { - _(this)[kSetMatrix]([1, 0, 0, 1, 0, 0]); + setMatrix(_(this), [1, 0, 0, 1, 0, 0]); } getTransform(): CanvasMatrix { @@ -2343,15 +2322,15 @@ export class CanvasRenderingContext2D { fill(pathOrRule?: Path2D | FillRule, rule?: FillRule): void { const slots = _(this); - const args = slots[kReadPathArgs](pathOrRule, rule); + const args = readPathArgs(pathOrRule, rule); const bitmap = slots.host.bitmap(); const mask = rasterize( - pathPolygons(slots[kSubpathsFor](args.path)), + pathPolygons(subpathsFor(slots, args.path)), bitmap.width, bitmap.height, args.rule, ); - slots[kPaint](mask, slots.state.fillStyle); + paintMask(slots, mask, slots.state.fillStyle); } stroke(path?: Path2D): void { @@ -2359,23 +2338,23 @@ export class CanvasRenderingContext2D { const bitmap = slots.host.bitmap(); const mask = rasterize( strokePolygons( - slots[kSubpathsFor](path), - slots[kStrokeStyle](), - slots[kScale](), + subpathsFor(slots, path), + getStrokeStyle(slots), + getTransformScale(slots), ), bitmap.width, bitmap.height, "nonzero", ); - slots[kPaint](mask, slots.state.strokeStyle); + paintMask(slots, mask, slots.state.strokeStyle); } clip(pathOrRule?: Path2D | FillRule, rule?: FillRule): void { const slots = _(this); - const args = slots[kReadPathArgs](pathOrRule, rule); + const args = readPathArgs(pathOrRule, rule); const bitmap = slots.host.bitmap(); const mask = rasterize( - pathPolygons(slots[kSubpathsFor](args.path)), + pathPolygons(subpathsFor(slots, args.path)), bitmap.width, bitmap.height, args.rule, @@ -2415,7 +2394,7 @@ export class CanvasRenderingContext2D { // The point is in canvas coordinates, unaffected by the transform. let winding = 0; let crossings = 0; - for (const points of pathPolygons(_(this)[kSubpathsFor](path))) { + for (const points of pathPolygons(subpathsFor(_(this), path))) { const count = points.length / 2; for (let i = 0; i < count; i++) { const ax = points[i * 2]; @@ -2447,9 +2426,9 @@ export class CanvasRenderingContext2D { } const slots = _(this); for (const points of strokePolygons( - slots[kSubpathsFor](path), - slots[kStrokeStyle](), - slots[kScale](), + subpathsFor(slots, path), + getStrokeStyle(slots), + getTransformScale(slots), )) { let inside = false; const count = points.length / 2; @@ -2476,7 +2455,7 @@ export class CanvasRenderingContext2D { return; } const slots = _(this); - slots[kPaint](slots[kRectMask](x, y, w, h), slots.state.fillStyle); + paintMask(slots, getRectMask(slots, x, y, w, h), slots.state.fillStyle); } strokeRect(x: number, y: number, w: number, h: number): void { @@ -2489,12 +2468,16 @@ export class CanvasRenderingContext2D { builder.rect(x, y, w, h); const bitmap = slots.host.bitmap(); const mask = rasterize( - strokePolygons(builder.subpaths, slots[kStrokeStyle](), slots[kScale]()), + strokePolygons( + builder.subpaths, + getStrokeStyle(slots), + getTransformScale(slots), + ), bitmap.width, bitmap.height, "nonzero", ); - slots[kPaint](mask, slots.state.strokeStyle); + paintMask(slots, mask, slots.state.strokeStyle); } clearRect(x: number, y: number, w: number, h: number): void { @@ -2502,7 +2485,7 @@ export class CanvasRenderingContext2D { return; } const slots = _(this); - const mask = slots[kRectMask](x, y, w, h); + const mask = getRectMask(slots, x, y, w, h); const clip = slots.state.clip; const data = slots.host.bitmap().data; for (let i = 0; i < mask.length; i++) { @@ -2517,7 +2500,7 @@ export class CanvasRenderingContext2D { data[at] = data[at + 1] = data[at + 2] = 0; } } - slots[kDropText](mask, 0.5); + dropText(slots, mask, 0.5); slots.host.changed(); } @@ -2655,7 +2638,7 @@ export class CanvasRenderingContext2D { }; // Large downscales average the source area a pixel covers, so a // photo drawn small does not alias. - const scale = slots[kScale](); + const scale = getTransformScale(slots); const shrinkX = Math.abs(sw / (dw * scale)); const shrinkY = Math.abs(sh / (dh * scale)); const average = smooth && (shrinkX > 2 || shrinkY > 2); @@ -2710,7 +2693,7 @@ export class CanvasRenderingContext2D { } } if (operation === "source-over" || operation === "copy") { - slots[kDropText](mask, 0.5); + dropText(slots, mask, 0.5); } slots.host.changed(); } @@ -2802,12 +2785,12 @@ export class CanvasRenderingContext2D { fillText(text: string, x: number, y: number, _maxWidth?: number): void { const slots = _(this); - slots[kDrawText](String(text), x, y, slots.state.fillStyle); + drawContextText(slots, String(text), x, y, slots.state.fillStyle); } strokeText(text: string, x: number, y: number, _maxWidth?: number): void { const slots = _(this); - slots[kDrawText](String(text), x, y, slots.state.strokeStyle); + drawContextText(slots, String(text), x, y, slots.state.strokeStyle); } /** Widths in canvas pixels: a cell's width in pixels for each column. */ @@ -2838,289 +2821,277 @@ export class CanvasRenderingContext2D { } drawFocusIfNeeded(): void {} +} - [kSetMatrix](this: Slots, m: Matrix): void { - if (!isFiniteAll(...m)) { - return; - } - const slots = this; - slots.state.transform = m; - slots.path.transform = m; +function setMatrix(slots: Slots, m: Matrix): void { + if (!isFiniteAll(...m)) { + return; } + slots.state.transform = m; + slots.path.transform = m; +} - // A Path2D's points are in user space and go through the transform - // when it is used; the current path's went through as they were added. - [kSubpathsFor]( - this: Slots, - path?: Path2D, - ): Subpath[] { - const slots = this; - if (path === undefined) { - return slots.path.subpaths; - } - const builder = new PathBuilder(); - builder.append(_(path)[kSubpaths], slots.state.transform); - return builder.subpaths; +// A Path2D's points are in user space and go through the transform +// when it is used; the current path's went through as they were added. +function subpathsFor( + slots: Slots, + path?: Path2D, +): Subpath[] { + if (path === undefined) { + return slots.path.subpaths; } + const builder = new PathBuilder(); + builder.append(_(path).builder.subpaths, slots.state.transform); + return builder.subpaths; +} - [kReadPathArgs]( - pathOrRule?: Path2D | FillRule, - maybeRule?: FillRule, - ): {path: Path2D | undefined; rule: FillRule} { - if (pathOrRule instanceof Path2D) { - return { - path: pathOrRule, - rule: maybeRule === "evenodd" ? "evenodd" : "nonzero", - }; - } +function readPathArgs( + pathOrRule?: Path2D | FillRule, + maybeRule?: FillRule, +): {path: Path2D | undefined; rule: FillRule} { + if (pathOrRule instanceof Path2D) { return { - path: undefined, - rule: pathOrRule === "evenodd" ? "evenodd" : "nonzero", + path: pathOrRule, + rule: maybeRule === "evenodd" ? "evenodd" : "nonzero", }; } + return { + path: undefined, + rule: pathOrRule === "evenodd" ? "evenodd" : "nonzero", + }; +} - [kStrokeStyle](this: Slots): StrokeStyle { - const slots = this; - const scale = slots[kScale](); - return { - width: slots.state.lineWidth, - cap: slots.state.lineCap, - join: slots.state.lineJoin, - miterLimit: slots.state.miterLimit, - dash: slots.state.lineDash.map((value) => value * scale), - dashOffset: slots.state.lineDashOffset * scale, - }; - } +function getStrokeStyle(slots: Slots): StrokeStyle { + const scale = getTransformScale(slots); + return { + width: slots.state.lineWidth, + cap: slots.state.lineCap, + join: slots.state.lineJoin, + miterLimit: slots.state.miterLimit, + dash: slots.state.lineDash.map((value) => value * scale), + dashOffset: slots.state.lineDashOffset * scale, + }; +} - // How much the transform scales lengths, for line widths and dashes. - [kScale](this: Slots): number { - const m = this.state.transform; - return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])); - } +// How much the transform scales lengths, for line widths and dashes. +function getTransformScale(slots: Slots): number { + const m = slots.state.transform; + return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])); +} - [kRectMask]( - this: Slots, - x: number, - y: number, - w: number, - h: number, - ): Float32Array { - const builder = new PathBuilder(); - const slots = this; - builder.transform = slots.state.transform; - builder.rect(x, y, w, h); - const bitmap = slots.host.bitmap(); - return rasterize( - pathPolygons(builder.subpaths), - bitmap.width, - bitmap.height, - "nonzero", - ); - } +function getRectMask( + slots: Slots, + x: number, + y: number, + w: number, + h: number, +): Float32Array { + const builder = new PathBuilder(); + builder.transform = slots.state.transform; + builder.rect(x, y, w, h); + const bitmap = slots.host.bitmap(); + return rasterize( + pathPolygons(builder.subpaths), + bitmap.width, + bitmap.height, + "nonzero", + ); +} - [kPaint]( - this: Slots, - mask: Float32Array, - paint: Paint, - ): void { - const slots = this; - const bitmap = slots.host.bitmap(); - const {width, height, data} = bitmap; - const operation = slots.state.globalCompositeOperation; - const unbounded = UNBOUNDED.has(operation); - const alpha = slots.state.globalAlpha; - const clip = slots.state.clip; - const solid = typeof paint === "string" ? parseCanvasColor(paint) : null; - const inverse = typeof paint === "string" - ? null - : invert(slots.state.transform); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const i = y * width + x; - const clipCover = clip === null ? 1 : clip[i]; - if (clipCover <= 0) { - continue; - } - const cover = mask[i]; - if (cover <= 0 && !unbounded) { - continue; - } - let color: RGBA | null = solid; - if (color === null && cover > 0 && inverse !== null) { - const [ux, uy] = apply(inverse, x + 0.5, y + 0.5); - color = _((paint as CanvasGradient | CanvasPattern))[kColorAt]( - ux, - uy, - ); - } - const sa = color === null ? 0 : color.a * alpha * cover; - if (clipCover < 1) { - // Blend the result back toward the old pixel where the clip - // only partly covers it. - const before = [ - data[i * 4], - data[i * 4 + 1], - data[i * 4 + 2], - data[i * 4 + 3], - ]; - composite( - data, - i * 4, - color?.r ?? 0, - color?.g ?? 0, - color?.b ?? 0, - sa, - operation, - ); - for (let c = 0; c < 4; c++) { - data[i * 4 + c] = - before[c] + (data[i * 4 + c] - before[c]) * clipCover; - } - } else { - composite( - data, - i * 4, - color?.r ?? 0, - color?.g ?? 0, - color?.b ?? 0, - sa, - operation, - ); - } +function paintMask( + slots: Slots, + mask: Float32Array, + paint: Paint, +): void { + const bitmap = slots.host.bitmap(); + const {width, height, data} = bitmap; + const operation = slots.state.globalCompositeOperation; + const unbounded = UNBOUNDED.has(operation); + const alpha = slots.state.globalAlpha; + const clip = slots.state.clip; + const solid = typeof paint === "string" ? parseCanvasColor(paint) : null; + const inverse = typeof paint === "string" + ? null + : invert(slots.state.transform); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + const clipCover = clip === null ? 1 : clip[i]; + if (clipCover <= 0) { + continue; } - } - if (operation === "source-over") { - slots[kCoverText]( - mask, - (x, y) => - solid ?? - (inverse === null - ? null - : _((paint as CanvasGradient | CanvasPattern))[kColorAt]( - ...apply(inverse, x + 0.5, y + 0.5), - )), - alpha, - ); - } else if (operation === "copy") { - slots[kDropText](mask, 0.5); - } - slots.host.changed(); - } - - // Translucent paint over text blends the text toward the paint, as it - // would blend the text's pixels in a browser, so drawing a dim fill - // every frame fades text out over several frames. - [kCoverText]( - this: Slots, - mask: Float32Array, - colorAt: (x: number, y: number) => RGBA | null, - alpha: number, - ): void { - const slots = this; - const bitmap = slots.host.bitmap(); - if (bitmap.width === 0 || bitmap.height === 0) { - return; - } - slots.text = slots.text.filter((run) => { - const x = Math.min(bitmap.width - 1, Math.max(0, Math.floor(run.x))); - const y = Math.min(bitmap.height - 1, Math.max(0, Math.floor(run.y))); - const cover = mask[y * bitmap.width + x]; - if (cover <= 0) { - return true; + const cover = mask[i]; + if (cover <= 0 && !unbounded) { + continue; } - const paint = colorAt(x, y); - const amount = paint === null ? 0 : paint.a * alpha * cover; - run.covered = 1 - (1 - run.covered) * (1 - amount); - if (run.covered >= 0.5) { - return false; + let color: RGBA | null = solid; + if (color === null && cover > 0 && inverse !== null) { + const [ux, uy] = apply(inverse, x + 0.5, y + 0.5); + color = getPaintColor(paint as CanvasGradient | CanvasPattern, ux, uy); } - if (paint !== null) { - run.color = { - r: run.color.r + (paint.r - run.color.r) * amount, - g: run.color.g + (paint.g - run.color.g) * amount, - b: run.color.b + (paint.b - run.color.b) * amount, - a: run.color.a, - }; + const sa = color === null ? 0 : color.a * alpha * cover; + if (clipCover < 1) { + // Blend the result back toward the old pixel where the clip + // only partly covers it. + const before = [ + data[i * 4], + data[i * 4 + 1], + data[i * 4 + 2], + data[i * 4 + 3], + ]; + composite( + data, + i * 4, + color?.r ?? 0, + color?.g ?? 0, + color?.b ?? 0, + sa, + operation, + ); + for (let c = 0; c < 4; c++) { + data[i * 4 + c] = + before[c] + (data[i * 4 + c] - before[c]) * clipCover; + } + } else { + composite( + data, + i * 4, + color?.r ?? 0, + color?.g ?? 0, + color?.b ?? 0, + sa, + operation, + ); } - return true; - }); - } - - // Text under the shape painted last is covered, as it would be - // in pixels. - [kDropText]( - this: Slots, - mask: Float32Array, - threshold: number, - ): void { - const slots = this; - const bitmap = slots.host.bitmap(); - if (bitmap.width === 0 || bitmap.height === 0) { - return; } - // An anchor on or past an edge (right-aligned text at the canvas's - // width, say) is read at the nearest pixel inside, so clearing the - // canvas clears it too. - slots.text = slots.text.filter((run) => { - const x = Math.min(bitmap.width - 1, Math.max(0, Math.floor(run.x))); - const y = Math.min(bitmap.height - 1, Math.max(0, Math.floor(run.y))); - return mask[y * bitmap.width + x] < threshold; - }); } + if (operation === "source-over") { + coverText( + slots, + mask, + (x, y) => + solid ?? + (inverse === null + ? null + : getPaintColor( + paint as CanvasGradient | CanvasPattern, + ...apply(inverse, x + 0.5, y + 0.5), + )), + alpha, + ); + } else if (operation === "copy") { + dropText(slots, mask, 0.5); + } + slots.host.changed(); +} - [kDrawText]( - this: Slots, - text: string, - x: number, - y: number, - paint: Paint, - ): void { - if (!isFiniteAll(x, y)) { - return; +// Translucent paint over text blends the text toward the paint, as it +// would blend the text's pixels in a browser, so drawing a dim fill +// every frame fades text out over several frames. +function coverText( + slots: Slots, + mask: Float32Array, + colorAt: (x: number, y: number) => RGBA | null, + alpha: number, +): void { + const bitmap = slots.host.bitmap(); + if (bitmap.width === 0 || bitmap.height === 0) { + return; + } + slots.text = slots.text.filter((run) => { + const x = Math.min(bitmap.width - 1, Math.max(0, Math.floor(run.x))); + const y = Math.min(bitmap.height - 1, Math.max(0, Math.floor(run.y))); + const cover = mask[y * bitmap.width + x]; + if (cover <= 0) { + return true; } - const slots = this; - const [px, py] = apply(slots.state.transform, x, y); - const color = typeof paint === "string" - ? parseCanvasColor(paint) - : _((paint as CanvasGradient | CanvasPattern))[kColorAt](x, y); - if (color === null || color.a * slots.state.globalAlpha === 0) { - return; + const paint = colorAt(x, y); + const amount = paint === null ? 0 : paint.a * alpha * cover; + run.covered = 1 - (1 - run.covered) * (1 - amount); + if (run.covered >= 0.5) { + return false; } - // "inherit" takes the canvas's direction as the text is drawn. - const direction = slots.state.direction === "inherit" - ? slots.host.direction() - : slots.state.direction; - const rtl = direction === "rtl"; - const align = slots.state.textAlign; - const resolved = align === "center" - ? "center" - : align === "right" || - (align === "end" && !rtl) || - (align === "start" && rtl) - ? "right" - : "left"; - const words = slots.state.font.toLowerCase().split(/\s+/); - const run: CanvasTextRun = { - text: toVisualOrder( - text.replace(/[\t\n\f\r]/g, " "), - rtl ? "rtl" : "ltr", - ), - x: px, - y: py, - align: resolved, - baseline: slots.state.textBaseline, - color: {...color, a: color.a * slots.state.globalAlpha}, - covered: 0, - bold: words.some( - (word) => word === "bold" || - word === "bolder" || - (/^\d+$/.test(word) && Number(word) >= 600), - ), - italic: words.includes("italic") || words.includes("oblique"), - }; - slots.text.push(run); - slots.host.changed(); + if (paint !== null) { + run.color = { + r: run.color.r + (paint.r - run.color.r) * amount, + g: run.color.g + (paint.g - run.color.g) * amount, + b: run.color.b + (paint.b - run.color.b) * amount, + a: run.color.a, + }; + } + return true; + }); +} + +// Text under the shape painted last is covered, as it would be +// in pixels. +function dropText( + slots: Slots, + mask: Float32Array, + threshold: number, +): void { + const bitmap = slots.host.bitmap(); + if (bitmap.width === 0 || bitmap.height === 0) { + return; + } + // An anchor on or past an edge (right-aligned text at the canvas's + // width, say) is read at the nearest pixel inside, so clearing the + // canvas clears it too. + slots.text = slots.text.filter((run) => { + const x = Math.min(bitmap.width - 1, Math.max(0, Math.floor(run.x))); + const y = Math.min(bitmap.height - 1, Math.max(0, Math.floor(run.y))); + return mask[y * bitmap.width + x] < threshold; + }); +} + +function drawContextText( + slots: Slots, + text: string, + x: number, + y: number, + paint: Paint, +): void { + if (!isFiniteAll(x, y)) { + return; + } + const [px, py] = apply(slots.state.transform, x, y); + const color = typeof paint === "string" + ? parseCanvasColor(paint) + : getPaintColor(paint as CanvasGradient | CanvasPattern, x, y); + if (color === null || color.a * slots.state.globalAlpha === 0) { + return; } + // "inherit" takes the canvas's direction as the text is drawn. + const direction = slots.state.direction === "inherit" + ? slots.host.direction() + : slots.state.direction; + const rtl = direction === "rtl"; + const align = slots.state.textAlign; + const resolved = align === "center" + ? "center" + : align === "right" || + (align === "end" && !rtl) || + (align === "start" && rtl) + ? "right" + : "left"; + const words = slots.state.font.toLowerCase().split(/\s+/); + const run: CanvasTextRun = { + text: toVisualOrder(text.replace(/[\t\n\f\r]/g, " "), rtl ? "rtl" : "ltr"), + x: px, + y: py, + align: resolved, + baseline: slots.state.textBaseline, + color: {...color, a: color.a * slots.state.globalAlpha}, + covered: 0, + bold: words.some( + (word) => word === "bold" || + word === "bolder" || + (/^\d+$/.test(word) && Number(word) >= 600), + ), + italic: words.includes("italic") || words.includes("oblique"), + }; + slots.text.push(run); + slots.host.changed(); } declareSealedRoot(CanvasRenderingContext2D); @@ -3177,10 +3148,6 @@ interface GridHost { drawsPixels(): boolean; } -const kResolveDirection = Symbol("resolveDirection"); -const kTextColor = Symbol("textColor"); -const kRect = Symbol("rect"); - type GridTextAlign = "start" | "end" | "left" | "right" | "center"; type GridDirection = "ltr" | "rtl" | "inherit"; type GridLineStyle = LineStyle["style"] | "none"; @@ -3220,9 +3187,6 @@ export interface CanvasCellGridContextSlots { gridHost: GridHost; gridState: GridState; gridStack: GridState[]; - [kResolveDirection]: Step; - [kTextColor]: Step; - [kRect]: Step; } export interface CanvasCellGridContext { @@ -3412,7 +3376,7 @@ export class CanvasCellGridContext { fillRect(x: number, y: number, w: number, h: number): void { const slots = _(this); - const rect = slots[kRect](x, y, w, h); + const rect = getGridRect(x, y, w, h); const fill = toGridFill(slots.gridState.fillStyle); if (rect === null || fill === undefined) { return; @@ -3424,7 +3388,7 @@ export class CanvasCellGridContext { clearRect(x: number, y: number, w: number, h: number): void { const slots = _(this); - const rect = slots[kRect](x, y, w, h); + const rect = getGridRect(x, y, w, h); if (rect === null) { return; } @@ -3442,12 +3406,12 @@ export class CanvasCellGridContext { return; } const slots = _(this); - const color = slots[kTextColor](slots.gridState.fillStyle); + const color = getGridTextColor(slots.gridState.fillStyle); if (color === undefined || (maxWidth !== undefined && !(maxWidth > 0))) { return; } const cells = slots.gridHost.cells(); - const rtl = slots[kResolveDirection]() === "rtl"; + const rtl = resolveGridDirection(slots) === "rtl"; const clusters = [ ...graphemeSegmenter.segment( toVisualOrder( @@ -3529,7 +3493,7 @@ export class CanvasCellGridContext { return; } const slots = _(this); - const color = slots[kTextColor](slots.gridState.strokeStyle); + const color = getGridTextColor(slots.gridState.strokeStyle); const style = slots.gridState.lineStyle; if (color === undefined || style === "none" || style === "hidden") { return; @@ -3554,8 +3518,8 @@ export class CanvasCellGridContext { /** A box on the cells `w` by `h` from (x, y), in strokeStyle and lineStyle. */ strokeRect(x: number, y: number, w: number, h: number): void { const slots = _(this); - const rect = slots[kRect](x, y, w, h); - const color = slots[kTextColor](slots.gridState.strokeStyle); + const rect = getGridRect(x, y, w, h); + const color = getGridTextColor(slots.gridState.strokeStyle); const style = slots.gridState.lineStyle; if ( rect === null || @@ -3585,7 +3549,7 @@ export class CanvasCellGridContext { measureText(text: string): {width: number} { const slots = _(this); return slots.gridHost.cells() - .measureText(toVisualOrder(String(text), slots[kResolveDirection]())); + .measureText(toVisualOrder(String(text), resolveGridDirection(slots))); } /** @@ -3740,47 +3704,48 @@ export class CanvasCellGridContext { slots.gridStack = []; slots.gridHost.changed(); } +} - [kResolveDirection](this: Slots): "ltr" | "rtl" { - const slots = this; - const {direction} = slots.gridState; - return direction === "inherit" ? slots.gridHost.direction() : direction; - } +function resolveGridDirection( + slots: Slots, +): "ltr" | "rtl" { + const {direction} = slots.gridState; + return direction === "inherit" ? slots.gridHost.direction() : direction; +} - // A kept color as a glyph's color: a cell color, null for the - // terminal's own text color, or undefined for one too transparent to - // show, as a 2d context's text under half opaque does not. - [kTextColor](value: string): number | null | undefined { - const system = getSystemColor(value); - if (system !== null) { - return system.cell === 0 ? null : system.cell; - } - const color = parseCanvasColor(value)!; - if (color.a < 0.5) { - return undefined; - } - return toCellColor((color.r << 16) | (color.g << 8) | color.b); +// A kept color as a glyph's color: a cell color, null for the +// terminal's own text color, or undefined for one too transparent to +// show, as a 2d context's text under half opaque does not. +function getGridTextColor(value: string): number | null | undefined { + const system = getSystemColor(value); + if (system !== null) { + return system.cell === 0 ? null : system.cell; + } + const color = parseCanvasColor(value)!; + if (color.a < 0.5) { + return undefined; } + return toCellColor((color.r << 16) | (color.g << 8) | color.b); +} - [kRect]( - x: number, - y: number, - w: number, - h: number, - ): [number, number, number, number] | null { - if (!isFiniteAll(x, y, w, h)) { - return null; - } - if (w < 0) { - x += w; - w = -w; - } - if (h < 0) { - y += h; - h = -h; - } - return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)]; +function getGridRect( + x: number, + y: number, + w: number, + h: number, +): [number, number, number, number] | null { + if (!isFiniteAll(x, y, w, h)) { + return null; + } + if (w < 0) { + x += w; + w = -w; + } + if (h < 0) { + y += h; + h = -h; } + return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)]; } declareSealedRoot(CanvasCellGridContext); From 5de8c09fb774dbfb67f24d949d367ea4da5a2e25 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Wed, 14 Oct 2026 17:52:20 -0400 Subject: [PATCH 15/99] Name internal steps with a leading underscore instead of a symbol, and drop fallbacks no runtime takes A class's internal steps were symbol-keyed methods that slots.ts moved off the public prototype onto the record. They are now methods whose names start with "_", which no Web IDL member's does, and slots.ts moves those instead, so a step reads slots._insertionSteps() rather than slots[kInsertionSteps](). Overrides and super calls work as before. A test holds that no interface a page can reach shows such a member, and that no member the specs name starts with one. Node 20.19, Bun and Deno all have DOMException, FormData, AbortController and AbortSignal, so the stand-ins for a runtime without them, the sealed DOMException and a FormData of our own among them, and the checks for their absence are gone. FormData extends the runtime's directly. Co-Authored-By: Claude Opus 5.5 --- src/internal/canvas.ts | 6 +- src/internal/cssom.ts | 195 ++++---- src/internal/dom.ts | 1002 +++++++++++++--------------------------- src/internal/slots.ts | 43 +- tests/membrane.test.ts | 33 +- tests/webidl.test.ts | 30 ++ 6 files changed, 468 insertions(+), 841 deletions(-) diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts index df48df81..f14c7c01 100644 --- a/src/internal/canvas.ts +++ b/src/internal/canvas.ts @@ -280,12 +280,8 @@ function copyRect( } } -// The platform's DOMException where there is one, which every runtime -// this engine supports has. function domException(name: string, message: string): Error { - const Constructor = (globalThis as {DOMException?: typeof DOMException}) - .DOMException; - return Constructor ? new Constructor(message, name) : new Error(message); + return new DOMException(message, name); } // --------------------------------------------------------------------------- diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index 6d7ccdd6..b73e2256 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -580,10 +580,6 @@ function createCSSNamespace(document: Document): typeof CSSNamespace { const inlineStyles = new WeakMap(); -const kComputedValue = Symbol("computedValue"); -const kCustomProperties = Symbol("customProperties"); -const kSync = Symbol("sync"); - // Declarations are stored as longhands. A shorthand write expands and a // shorthand read reconstructs. An element-owned block and the `style` // attribute are one store seen from two sides: a property write @@ -612,7 +608,7 @@ interface CSSStyleDeclarationSlots { block: CSSValues.DeclarationBlock | null; // How many numeric index properties currently name a declaration. indexed: number; - [kSync]: Step; + _sync: Step; } interface CSSStyleDeclaration { @@ -658,7 +654,7 @@ class CSSStyleDeclaration { syncViewIndices(this); return slots.viewOf.length; } - slots[kSync]!(); + slots._sync!(); return slots.declarations.length; } @@ -667,7 +663,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.cssText; } - slots[kSync]!(); + slots._sync!(); return serializeDeclarations(this); } @@ -677,7 +673,7 @@ class CSSStyleDeclaration { slots.viewOf.cssText = text; return; } - slots[kSync]!(); + slots._sync!(); slots.declarations = []; slots.byName.clear(); for (const declaration of CSSValues.parseDeclarationText(text ?? "")) { @@ -700,7 +696,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.item(index); } - slots[kSync]!(); + slots._sync!(); return slots.declarations[index]?.name ?? ""; } @@ -710,7 +706,7 @@ class CSSStyleDeclaration { syncViewIndices(this); return slots.viewOf[Symbol.iterator](); } - slots[kSync]!(); + slots._sync!(); return slots.declarations.map((entry) => entry.name)[Symbol.iterator](); } @@ -719,7 +715,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.getPropertyValue(property); } - slots[kSync]!(); + slots._sync!(); const name = CSSValues.normalizePropertyName(property); const declared = findDeclaration(this, name); if (declared) { @@ -734,7 +730,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.getPropertyPriority(property); } - slots[kSync]!(); + slots._sync!(); const name = CSSValues.normalizePropertyName(property); const declared = findDeclaration(this, name); if (declared) { @@ -756,7 +752,7 @@ class CSSStyleDeclaration { slots.viewOf.setProperty(property, value, priority); return; } - slots[kSync]!(); + slots._sync!(); const name = CSSValues.normalizePropertyName(property); if (!isSupportedDeclaration(this, name)) { return; @@ -787,7 +783,7 @@ class CSSStyleDeclaration { if (slots.viewOf !== undefined) { return slots.viewOf.removeProperty(property); } - slots[kSync]!(); + slots._sync!(); const name = CSSValues.normalizePropertyName(property); const previous = this.getPropertyValue(name); let changed = removeDeclaration(this, name); @@ -804,7 +800,7 @@ class CSSStyleDeclaration { * Reparse the `style` attribute if it changed since this object last wrote * it. */ - [kSync]?(this: Slots): void { + _sync?(this: Slots): void { const slots = this; if (!slots.element) { return; @@ -841,7 +837,7 @@ function getDeclarationBlock( style: CSSStyleDeclaration, ): CSSValues.DeclarationBlock { const styleSlots = _(style); - styleSlots[kSync]!(); + styleSlots._sync!(); if (styleSlots.declarations.length === 0) { return EMPTY_DECLARATIONS; } @@ -2824,7 +2820,7 @@ interface CSSStyleSheetSlots { title: string | null; // The owner node's text CSSStyleSheet sheet last parsed. text: string | null; - [kSync]: Step; + _sync: Step; } interface CSSStyleSheet { @@ -2862,7 +2858,7 @@ class CSSStyleSheet { get cssRules(): CSSRuleList { const slots = _(this); - slots[kSync]!(); + slots._sync!(); return slots.ruleList; } @@ -2923,7 +2919,7 @@ class CSSStyleSheet { throw typeError("insertRule requires a rule", this); } const slots = _(this); - slots[kSync]!(); + slots._sync!(); if (index > slots.rules.length) { throw domException( `Cannot insert at index ${index}`, @@ -2953,7 +2949,7 @@ class CSSStyleSheet { throw typeError("deleteRule requires an index", this); } const slots = _(this); - slots[kSync]!(); + slots._sync!(); if (index >= slots.rules.length) { throw domException( `Cannot delete at index ${index}`, @@ -2990,7 +2986,7 @@ class CSSStyleSheet { } removeRule(index = 0): void { - _(this)[kSync]!(); + _(this)._sync!(); if (index >= this.cssRules.length) { throw domException( `Cannot delete at index ${index}`, @@ -3032,7 +3028,7 @@ class CSSStyleSheet { } // Reparse the owner element's text if it changed. - [kSync]?(this: Slots): void { + _sync?(this: Slots): void { const slots = this; const node = slots.ownerNode; if (!node || node.tagName !== "STYLE") { @@ -3888,7 +3884,7 @@ function getUAStyleSheet(): CSSStyleSheet { interface MeasuredSlots { element: Element; cascade: Cascade | null; - [kComputedValue](property: string): string; + _computedValue(property: string): string; } interface MeasuredDeclaration { @@ -3933,10 +3929,10 @@ export function getComputedValue( return ""; } return pseudoElement - ? _(getPseudoDeclaration(cascade, element, pseudoElement))[kComputedValue]( + ? _(getPseudoDeclaration(cascade, element, pseudoElement))._computedValue( property, ) - : cascade.computedSlotsFor(element)[kComputedValue](property); + : cascade.computedSlotsFor(element)._computedValue(property); } // A box with nothing to contain or transform: no box at all, or a plain @@ -4086,10 +4082,6 @@ function defineLonghandIndices(prototype: object): void { } } -const kSyncResolved = Symbol("syncResolved"); -const kUsedValue = Symbol("usedValue"); -const kBaseValue = Symbol("baseValue"); - // Writing a computed style is an error, not a no-op. It throws the // document's own DOMException, since one from another global is not // what an author catches. @@ -4323,11 +4315,11 @@ interface ComputedStyleDeclarationSlots extends CSSStyleDeclarationSlots { // for the paint style to be derived again. styleVersion: object; custom: string[] | null; - [kCustomProperties]: Step; - [kUsedValue]: Step; - [kBaseValue]: Step; - [kSyncResolved]: Step; - [kComputedValue]: Step; + _customProperties: Step; + _usedValue: Step; + _baseValue: Step; + _syncResolved: Step; + _computedValue: Step; } interface ComputedStyleDeclaration { @@ -4384,16 +4376,16 @@ class ComputedStyleDeclaration extends CSSStyleProperties { } const current = slots.cascade?.currentDeclarations; if (current !== undefined && !current.has(slots)) { - slots[kSyncResolved](); + slots._syncResolved(); } // A flow-relative longhand resolves as the physical longhand it maps // to: same slot, same measurement, same result. property = toPhysicalProperty(this, property); if (slots.cascade && USED_VALUE_PROPERTIES.has(property)) { - return slots[kUsedValue](property); + return slots._usedValue(property); } if (slots.cascade && MIN_SIZE_PROPERTIES.has(property)) { - return getResolvedMinSize(this, slots[kComputedValue](property)); + return getResolvedMinSize(this, slots._computedValue(property)); } if (slots.cascade && USED_TRACK_PROPERTIES.has(property)) { const tracks = usedGridTracks( @@ -4413,9 +4405,9 @@ class ComputedStyleDeclaration extends CSSStyleProperties { this.getPropertyValue(longhand), ); } - let computed = slots[kComputedValue](property); + let computed = slots._computedValue(property); if (AUTO_COLOR_PROPERTIES.has(property) && computed === "auto") { - computed = slots[kComputedValue]("color"); + computed = slots._computedValue("color"); } // A system color computes as its keyword, and reads back as the color // it stands for here. @@ -4454,7 +4446,7 @@ class ComputedStyleDeclaration extends CSSStyleProperties { return [...CSS_LONGHANDS, ...getCustomNames(this)][Symbol.iterator](); } - [kCustomProperties](this: Slots): string[] { + _customProperties(this: Slots): string[] { const names: string[] = []; for (const rule of this.cssRules) { for (const name of Object.keys(rule.declarations)) { @@ -4476,10 +4468,7 @@ class ComputedStyleDeclaration extends CSSStyleProperties { // Measured through the same flush a geometry read takes, and memoized // behind it, so a property-heavy caller measures once per layout. The // memo belongs to the cascade, which lets a flush drop every one. - [kUsedValue]( - this: Slots, - property: string, - ): string { + _usedValue(this: Slots, property: string): string { const slots = this; const cascade = slots.cascade!; const used = getUsedValues(cascade, ownerOf(this)); @@ -4488,7 +4477,7 @@ class ComputedStyleDeclaration extends CSSStyleProperties { return memoized; } - const computed = slots[kComputedValue](property); + const computed = slots._computedValue(property); const value = measureUsedValue(ownerOf(this), property, computed); used.set(property, value); return value; @@ -4497,17 +4486,14 @@ class ComputedStyleDeclaration extends CSSStyleProperties { // The cascade's value before a running transition overrides it, meaning // the after-change style. An interpolated value moves every frame and // must never enter the memo. - [kBaseValue]( - this: Slots, - property: string, - ): string { + _baseValue(this: Slots, property: string): string { const slots = this; let value = slots.resolved.get(property); if (value === undefined) { const longhands = CSSValues.getLonghands(property); value = longhands ? CSSValues.resolveShorthand(property, longhands, (longhand) => - slots[kBaseValue](longhand), + slots._baseValue(longhand), ) : getAbsolutizedValue( ownerOf(this), @@ -4520,7 +4506,7 @@ class ComputedStyleDeclaration extends CSSStyleProperties { // Reads call this only when the cascade no longer vouches for this // declaration. It runs under every property read of every element. - [kSyncResolved](this: Slots): void { + _syncResolved(this: Slots): void { const slots = this; if (!slots.cascade) { return; @@ -4547,21 +4533,21 @@ class ComputedStyleDeclaration extends CSSStyleProperties { processTransitionStyle( slots.cascade, slots.element, - (property) => slots[kBaseValue](property), + (property) => slots._baseValue(property), "", ); } - [kComputedValue]( + _computedValue( this: Slots, property: string, ): string { const slots = this; const current = slots.cascade?.currentDeclarations; if (current !== undefined && !current.has(this)) { - slots[kSyncResolved](); + slots._syncResolved(); } - const value = slots[kBaseValue](property); + const value = slots._baseValue(property); const cascade = slots.cascade; if (cascade !== null && cascade.activeTransitions.size > 0) { const transitional = getTransitionValue( @@ -4632,7 +4618,7 @@ function getLengthContext( const font = CSSValues.getFontSize( own ? parent ? getComputedValue(parent, "font-size") : "" - : declarationSlots[kComputedValue]("font-size"), + : declarationSlots._computedValue("font-size"), initial, ); const root = getRootFontSize(declaration, own); @@ -4711,7 +4697,7 @@ function getRootFontSize( !root || (ownFontSize && root === declarationSlots.element) ? "" : root === declarationSlots.element - ? declarationSlots[kComputedValue]("font-size") + ? declarationSlots._computedValue("font-size") : getComputedValue(root, "font-size"), getCellSize(declarationSlots.element).height, ); @@ -4727,7 +4713,7 @@ function toPhysicalProperty( return ( CSSValues.getPhysicalProperty( property, - _(declaration)[kComputedValue]("direction"), + _(declaration)._computedValue("direction"), ) ?? property ); } @@ -4742,7 +4728,7 @@ function measureUsedValue( // A border with no style draws nothing and takes no space, whatever // width it declares. if (property.startsWith("border-") && property.endsWith("-width")) { - const style = _(declaration)[kComputedValue]( + const style = _(declaration)._computedValue( `${property.slice(0, -"-width".length)}-style`, ); if (!style || style === "none" || style === "hidden") { @@ -4773,7 +4759,7 @@ function measureUsedValue( const border = vertical ? rect.height : rect.width; // The used value of width is the box box-sizing names, so a // border-box element reads its border box (css-sizing-3 §3.1). - if (_(declaration)[kComputedValue]("box-sizing") === "border-box") { + if (_(declaration)._computedValue("box-sizing") === "border-box") { return CSSValues.getUsedLength(Math.max(0, border)); } const edges = @@ -4843,7 +4829,7 @@ function getUsedInset( const opposite = OPPOSITE_INSET[property]; const other = CSSValues.getInsetLength( - _(declaration)[kComputedValue](opposite), + _(declaration)._computedValue(opposite), basis, ); // A relatively positioned box is offset from where it already was, so @@ -5004,7 +4990,7 @@ function getResolvedMinSize( return "0px"; } } - if (declarationSlots[kComputedValue]("aspect-ratio") !== "auto") { + if (declarationSlots._computedValue("aspect-ratio") !== "auto") { return "auto"; } const parent = flatParentElement(declarationSlots.element); @@ -5266,7 +5252,7 @@ function resolvePropertyValue( if ( property === "text-align" && CSSValues.getLegacyAlignment(value) !== null && - /^(?:inline-)?table$/.test(declarationSlots[kComputedValue]("display")) + /^(?:inline-)?table$/.test(declarationSlots._computedValue("display")) ) { return "start"; } @@ -5327,7 +5313,7 @@ function resolveCascadedValue( // on. return property === "color" ? (resolveFromParent(declaration, "color") ?? "") - : _(declaration)[kComputedValue]("color"); + : _(declaration)._computedValue("color"); } return value; } @@ -5348,7 +5334,7 @@ function resolvePropertyValueRaw( name === property || CSSValues.getPhysicalProperty( name, - (direction ??= declarationSlots[kComputedValue]("direction")), + (direction ??= declarationSlots._computedValue("direction")), ) === property; const inline = getInlineDeclarations(declaration); @@ -5483,7 +5469,7 @@ function getCustomNames(computed: ComputedStyleDeclaration): string[] { const computedSlots = _(computed); const current = computedSlots.cascade?.currentDeclarations; if (current !== undefined && !current.has(computedSlots)) { - computedSlots[kSyncResolved](); + computedSlots._syncResolved(); } if (computedSlots.custom) { return computedSlots.custom; @@ -5495,7 +5481,7 @@ function getCustomNames(computed: ComputedStyleDeclaration): string[] { element = flatParentElement(element) ) { const declaration = computedSlots.cascade?.declarationFor(element); - for (const name of _maybe(declaration)?.[kCustomProperties]() ?? []) { + for (const name of _maybe(declaration)?._customProperties() ?? []) { names.add(name); } } @@ -5518,10 +5504,10 @@ interface PseudoStyleDeclarationSlots extends CSSStyleDeclarationSlots { cascade: Cascade | null; nodeResolved: Map; boxView: MeasuredDeclaration | null; - [kSyncResolved]: Step; - [kBaseValue]: Step; - [kUsedValue]: Step; - [kComputedValue]: Step; + _syncResolved: Step; + _baseValue: Step; + _usedValue: Step; + _computedValue: Step; } interface PseudoStyleDeclaration { @@ -5568,12 +5554,12 @@ class PseudoStyleDeclaration extends CSSStyleProperties { const slots = _(this); const current = slots.cascade?.currentDeclarations; if (current !== undefined && !current.has(slots)) { - slots[kSyncResolved](); + slots._syncResolved(); } let value = slots.nodeResolved.get(property); if (value === undefined) { value = - slots[kBaseValue](property) || + slots._baseValue(property) || CSSValues.getComputedValueEntry( property, getInitialStyle(null, property), @@ -5590,13 +5576,13 @@ class PseudoStyleDeclaration extends CSSStyleProperties { flushCascadeStyle(slots.cascade); } const computed = - slots[kComputedValue](property) || + slots._computedValue(property) || CSSValues.getComputedValueEntry( property, getInitialStyle(null, property), ); if (slots.cascade && USED_VALUE_PROPERTIES.has(property)) { - return slots[kUsedValue](property, computed); + return slots._usedValue(property, computed); } return computed; } @@ -5617,7 +5603,7 @@ class PseudoStyleDeclaration extends CSSStyleProperties { return CSS_LONGHANDS[index] ?? ""; } - [kSyncResolved](this: Slots): void { + _syncResolved(this: Slots): void { // Before the work, because resolving below reads back through this // declaration. const slots = this; @@ -5644,21 +5630,21 @@ class PseudoStyleDeclaration extends CSSStyleProperties { processTransitionStyle( slots.cascade, slots.element, - (property) => slots[kBaseValue](property), + (property) => slots._baseValue(property), slots.pseudoElement, ); } } // The cascade's declarations alone, with no transition overriding them. - [kBaseValue](this: Slots, property: string): string { + _baseValue(this: Slots, property: string): string { const slots = this; let value = slots.resolved.get(property); if (value === undefined) { const longhands = CSSValues.getLonghands(property); value = longhands && slots.pseudoDeclarations[property] === undefined ? CSSValues.serializeShorthandValue(property, longhands, (longhand) => - slots[kBaseValue](longhand) || CSS_INITIAL_VALUES[longhand] || "") + slots._baseValue(longhand) || CSS_INITIAL_VALUES[longhand] || "") : CSSValues.getComputedValueEntry( property, slots.pseudoDeclarations[property] ?? "", @@ -5672,7 +5658,7 @@ class PseudoStyleDeclaration extends CSSStyleProperties { // through the same arithmetic as an element's metrics. A pseudo-element // never given a node uses the percentage resolution below, against the // box it would be in. - [kUsedValue]( + _usedValue( this: Slots, property: string, computed: string, @@ -5724,16 +5710,16 @@ class PseudoStyleDeclaration extends CSSStyleProperties { // The engine's read. An empty result means no rule reached the // pseudo-element, which is what the ::selection and ::marker painters // check. - [kComputedValue]( + _computedValue( this: Slots, property: string, ): string { const slots = this; const current = slots.cascade?.currentDeclarations; if (current !== undefined && !current.has(this)) { - slots[kSyncResolved](); + slots._syncResolved(); } - const value = slots[kBaseValue](property); + const value = slots._baseValue(property); const transitional = getPseudoTransitionValue(ownerOf(this), property); return transitional ?? value; } @@ -5781,7 +5767,7 @@ function getBoxView( adoptSlots(measured, { element: node, cascade: declarationSlots.cascade, - [kComputedValue]: (property: string): string => + _computedValue: (property: string): string => declaration.nodeValue(property), }); view = measured as MeasuredDeclaration; @@ -6120,9 +6106,9 @@ function shouldCreatePseudoElement( ): boolean { if (pseudoType === "::marker") { const computedStyle = cascade.declarationFor(element); - const display = _(computedStyle)[kComputedValue]("display"); + const display = _(computedStyle)._computedValue("display"); const listStylePosition = - _(computedStyle)[kComputedValue]("list-style-position") || "outside"; + _(computedStyle)._computedValue("list-style-position") || "outside"; if (display === "list-item" && listStylePosition !== "outside") { return true; @@ -6204,7 +6190,7 @@ export class Cascade { // The slots of every declaration resolved against the current cascade. // Dropping one, or replacing the set, sends it back through - // kSyncResolved on its next read. Weak, so a declaration nobody holds + // _syncResolved on its next read. Weak, so a declaration nobody holds // costs nothing. currentDeclarations: WeakSet; @@ -6794,11 +6780,11 @@ export class Cascade { slots = fresh; this.computedStyleCache.set(element, fresh); // Building a fresh declaration is the other form of the style - // change event kSyncResolved sees. + // change event _syncResolved sees. processTransitionStyle( this, element, - (property) => fresh[kBaseValue](property), + (property) => fresh._baseValue(property), "", ); } @@ -6815,7 +6801,7 @@ export class Cascade { } const declarationSlots = this.computedSlotsFor(element); if (!this.currentDeclarations.has(declarationSlots)) { - declarationSlots[kSyncResolved](); + declarationSlots._syncResolved(); } return declarationSlots.styleVersion; } @@ -6860,7 +6846,7 @@ export class Cascade { } const computedStyle = this.declarationFor(hostElement); - const display = _(computedStyle)[kComputedValue]("display"); + const display = _(computedStyle)._computedValue("display"); if (display !== "list-item") { return null; @@ -7228,7 +7214,7 @@ function pseudoDeclarationsFor( const hostStyleSlots = _(hostStyle); for (const property of inheritedProperties) { if (!declarations[property]) { - const inherited = hostStyleSlots[kComputedValue](property); + const inherited = hostStyleSlots._computedValue(property); if (inherited) { declarations[property] = inherited; } @@ -7236,7 +7222,7 @@ function pseudoDeclarationsFor( } // A pseudo-element of a flex or grid container is one of its items, // and an item's display isBlockified, including the initial `inline`. - if (ITEM_DISPLAYS.has(hostStyleSlots[kComputedValue]("display"))) { + if (ITEM_DISPLAYS.has(hostStyleSlots._computedValue("display"))) { declarations.display = CSSValues.getBlockifiedDisplay( declarations.display || getInitialStyle(null, "display"), ); @@ -7513,7 +7499,7 @@ function getPseudoDeclaration( processTransitionStyle( cascade, element, - (property) => _(declaration)[kBaseValue](property), + (property) => _(declaration)._baseValue(property), pseudoElement, ); return declaration; @@ -7643,9 +7629,9 @@ function attachPseudoElementsToDocument(cascade: Cascade): void { } for (const element of listItems) { const computedStyle = cascade.declarationFor(element); - const display = _(computedStyle)[kComputedValue]("display"); + const display = _(computedStyle)._computedValue("display"); const listStylePosition = - _(computedStyle)[kComputedValue]("list-style-position") || "outside"; + _(computedStyle)._computedValue("list-style-position") || "outside"; if (display === "list-item" && listStylePosition !== "outside") { attachPseudoElementToElementForType(cascade, element, "::marker"); @@ -7686,9 +7672,9 @@ function initializeCounters(cascade: Cascade, element: Element): void { const computedStyle = cascade.declarationFor(element); const computedStyleSlots = _(computedStyle); - const counterReset = computedStyleSlots[kComputedValue]("counter-reset"); + const counterReset = computedStyleSlots._computedValue("counter-reset"); const counterIncrement = - computedStyleSlots[kComputedValue]("counter-increment"); + computedStyleSlots._computedValue("counter-increment"); const parentElement = element.parentElement; const parentScope = parentElement @@ -10179,8 +10165,9 @@ function computePseudoElementStyle( if (!CSSValues.hasSlotAliases(name)) { continue; } - direction ??= - cascade.computedSlotsFor(element)[kComputedValue]("direction"); + direction ??= cascade + .computedSlotsFor(element) + ._computedValue("direction"); for (const other of CSSValues.getSlotNames(name, direction)) { computedStyle[other] = value; } @@ -10200,11 +10187,11 @@ function getPseudoContent( if (pseudoType === "::marker") { const computedStyle = cascade.declarationFor(hostElement); - const display = _(computedStyle)[kComputedValue]("display"); + const display = _(computedStyle)._computedValue("display"); if (display === "list-item") { const listStylePosition = - _(computedStyle)[kComputedValue]("list-style-position") || "outside"; + _(computedStyle)._computedValue("list-style-position") || "outside"; if (listStylePosition === "outside") { return null; @@ -10361,9 +10348,9 @@ function attachPseudoElementToElementForType( if (pseudoType === "::marker") { const computedStyle = cascade.declarationFor(element); - const display = _(computedStyle)[kComputedValue]("display"); + const display = _(computedStyle)._computedValue("display"); const listStylePosition = - _(computedStyle)[kComputedValue]("list-style-position") || "outside"; + _(computedStyle)._computedValue("list-style-position") || "outside"; if (display !== "list-item") { return; diff --git a/src/internal/dom.ts b/src/internal/dom.ts index ef46c55b..1fbabc63 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -124,15 +124,12 @@ export const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"; export const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"; const XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/"; -const kEnsureUAShadowTree = Symbol("build a control's UA shadow tree"); - // Safe to call more than once, and synchronous: the shadow tree exists // when this returns. A control that was removed and re-inserted keeps its // tree and only updates the state it missed. function ensureUAShadowTree(element: globalThis.Element): void { - (_(element) as unknown as Record void) | undefined>)[ - kEnsureUAShadowTree - ]?.(); + (_(element) as unknown as Record void) | undefined>) + ._ensureUAShadowTree?.(); } // Built-in tags that get a UA shadow tree when they connect. @@ -180,8 +177,6 @@ function ensureUAShadowTrees(root: globalThis.Node): void { } } -const kUASelection = Symbol("a control's selection, whatever its type"); - /** * A text control's selection record, without the type check authors get. * Per spec selectionStart is null on a number input, but the UA still needs @@ -191,13 +186,11 @@ export function getSelectionRecord( control: globalThis.Element, ): {start: number; end: number; direction: string} | null { const slots = _(control) as unknown as { - [kUASelection]?: () => ReturnType; + _uaSelection?: () => ReturnType; }; - return slots[kUASelection]?.() ?? null; + return slots._uaSelection?.() ?? null; } -const kSetUASelection = Symbol("move a control's selection, whatever its type"); - /** Set a text control's selection, without the type check authors get. */ export function setUASelection( control: globalThis.Element, @@ -207,17 +200,15 @@ export function setUASelection( ): void { ( _(control) as unknown as { - [kSetUASelection]( + _setUASelection( start: number, end: number, direction?: string, ): void; } - )[kSetUASelection]!(start, end, direction); + )._setUASelection!(start, end, direction); } -const kSyncUAShadowTree = Symbol("bring a control's UA tree back into step"); - // The pseudo-classes that read a control's state rather than its // attributes, each with its bit in a snapshot of what an element matches. // :autofill and :blank match nothing here, so they never change. @@ -285,9 +276,8 @@ function reportControlStates(element: Element): void { // selected. Its shadow tree redraws, and the rules that read that state // are told, since no mutation record says so. function syncUAShadowTree(element: Element): void { - (_(element) as unknown as Record void) | undefined>)[ - kSyncUAShadowTree - ]?.(); + (_(element) as unknown as Record void) | undefined>) + ._syncUAShadowTree?.(); reportControlStates(element); // A form and every fieldset around the control are :valid or :invalid // by what they hold, so they are told as well. @@ -329,9 +319,6 @@ export function takesTextCursor( return isTextControl(element) && !CURSORLESS_INPUT_TYPES.has(element.type!); } -const kUAValueText = - Symbol("the text node a control's editable value lives in"); - /** * The text node holding a form control's value inside its UA shadow tree, * or null before the tree is built. The editable text lives at @@ -342,10 +329,8 @@ export function getTextControlValueText( textControl: globalThis.Element, ): globalThis.Text | null { return ( - (_(textControl) as unknown as Record< - symbol, - globalThis.Text | null | undefined - >)[kUAValueText] ?? null + (_(textControl) as unknown as {_uaValueText?: globalThis.Text | null}) + ._uaValueText ?? null ); } @@ -418,8 +403,6 @@ export function placeTextControlCaret( return {textControl: target, offset}; } -const kUASelectionRange = Symbol("what an element's own selection covers"); - /** * The focused text control's selection over this text node, or null. Per * spec a control's selection is invisible to getSelection(), so this is @@ -438,7 +421,7 @@ export function getTextControlSelectionRange( const range = _( (active as HTMLInputElement | HTMLTextAreaElement), - )[kUASelectionRange]?.() ?? + )._uaSelectionRange?.() ?? null; if (!range || range.startContainer !== textNode) { return null; @@ -583,8 +566,6 @@ function moveTextControlSelection( return {value, start: clamped, end: clamped, direction: "none"}; } -const kUAValue = Symbol("a text control's value, beneath the IDL attribute"); - // Handles Backspace, Delete, the horizontal arrows and the readline chords, // grapheme-aware. Returns null for any other key. Enter, vertical motion // and Home/End are left to the control, which knows where its lines end, @@ -596,7 +577,7 @@ function applySharedTextControlEdit( ctrlKey: boolean, altKey: boolean, ): TextControlEditResult | null { - const value = _(textControl)[kUAValue]; + const value = _(textControl)._uaValue; const {start, end, direction} = getSelectionRecord(textControl)!; const backward = direction === "backward"; const caret = backward ? start : end; @@ -782,7 +763,7 @@ function printableTextControlEdit( textControl: HTMLInputElement | HTMLTextAreaElement, text: string, ): TextControlEditResult { - const value = _(textControl)[kUAValue]; + const value = _(textControl)._uaValue; const {start, end} = getSelectionRecord(textControl)!; return createCollapsedEdit( value.slice(0, start) + text + value.slice(end), @@ -824,8 +805,6 @@ function createCollapsedEdit( return {value, start: clamped, end: clamped, direction: "none"}; } -const kSetUAValue = Symbol("write a text control's value, as a user edit does"); - // Fires input when the value actually changed and select when the user // moved the selection. Writes to the control's value directly, never // through the value IDL setter. In a browser a user edit changes the value @@ -886,7 +865,7 @@ function applyTextControlEdit( kind: TextControlEditKind = {inputType: "", asked: true}, ): void { const textControlSlots = _(textControl); - const value = textControlSlots[kUAValue]; + const value = textControlSlots._uaValue; const {start, end, direction} = getSelectionRecord(textControl)!; if (result.value !== value) { const init = { @@ -907,8 +886,8 @@ function applyTextControlEdit( return; } editedSinceChange.add(textControl); - textControlSlots[kSetUAValue]!(result.value); - textControlSlots[kSetUASelection]!( + textControlSlots._setUAValue!(result.value); + textControlSlots._setUASelection!( result.start, result.end, result.direction, @@ -922,7 +901,7 @@ function applyTextControlEdit( result.end !== end || (result.start !== result.end && result.direction !== direction) ) { - textControlSlots[kSetUASelection]!( + textControlSlots._setUASelection!( result.start, result.end, result.direction, @@ -1104,169 +1083,7 @@ function replaceTextRange( } } -// WebIDL §3.14.1: the legacy code of each name that had one, 0 for the -// rest. -const DOM_EXCEPTION_CODES = new Map([ - ["IndexSizeError", 1], - ["DOMStringSizeError", 2], - ["HierarchyRequestError", 3], - ["WrongDocumentError", 4], - ["InvalidCharacterError", 5], - ["NoDataAllowedError", 6], - ["NoModificationAllowedError", 7], - ["NotFoundError", 8], - ["NotSupportedError", 9], - ["InUseAttributeError", 10], - ["InvalidStateError", 11], - ["SyntaxError", 12], - ["InvalidModificationError", 13], - ["NamespaceError", 14], - ["InvalidAccessError", 15], - ["ValidationError", 16], - ["TypeMismatchError", 17], - ["SecurityError", 18], - ["NetworkError", 19], - ["AbortError", 20], - ["URLMismatchError", 21], - ["QuotaExceededError", 22], - ["TimeoutError", 23], - ["InvalidNodeTypeError", 24], - ["DataCloneError", 25], -]); - -export interface SealedDOMExceptionSlots { - exceptionName: string; - exceptionMessage: string; -} - -export interface SealedDOMException { - readonly [internals]: SealedDOMExceptionSlots; -} - -/** - * WebIDL's DOMException, for a runtime that has none. Its prototype's is - * Error's, as WebIDL says. - */ -export class SealedDOMException - extends Error - implements globalThis.DOMException { - static readonly INDEX_SIZE_ERR = 1; - static readonly DOMSTRING_SIZE_ERR = 2; - static readonly HIERARCHY_REQUEST_ERR = 3; - static readonly WRONG_DOCUMENT_ERR = 4; - static readonly INVALID_CHARACTER_ERR = 5; - static readonly NO_DATA_ALLOWED_ERR = 6; - static readonly NO_MODIFICATION_ALLOWED_ERR = 7; - static readonly NOT_FOUND_ERR = 8; - static readonly NOT_SUPPORTED_ERR = 9; - static readonly INUSE_ATTRIBUTE_ERR = 10; - static readonly INVALID_STATE_ERR = 11; - static readonly SYNTAX_ERR = 12; - static readonly INVALID_MODIFICATION_ERR = 13; - static readonly NAMESPACE_ERR = 14; - static readonly INVALID_ACCESS_ERR = 15; - static readonly VALIDATION_ERR = 16; - static readonly TYPE_MISMATCH_ERR = 17; - static readonly SECURITY_ERR = 18; - static readonly NETWORK_ERR = 19; - static readonly ABORT_ERR = 20; - static readonly URL_MISMATCH_ERR = 21; - static readonly QUOTA_EXCEEDED_ERR = 22; - static readonly TIMEOUT_ERR = 23; - static readonly INVALID_NODE_TYPE_ERR = 24; - static readonly DATA_CLONE_ERR = 25; - - declare readonly INDEX_SIZE_ERR: 1; - declare readonly DOMSTRING_SIZE_ERR: 2; - declare readonly HIERARCHY_REQUEST_ERR: 3; - declare readonly WRONG_DOCUMENT_ERR: 4; - declare readonly INVALID_CHARACTER_ERR: 5; - declare readonly NO_DATA_ALLOWED_ERR: 6; - declare readonly NO_MODIFICATION_ALLOWED_ERR: 7; - declare readonly NOT_FOUND_ERR: 8; - declare readonly NOT_SUPPORTED_ERR: 9; - declare readonly INUSE_ATTRIBUTE_ERR: 10; - declare readonly INVALID_STATE_ERR: 11; - declare readonly SYNTAX_ERR: 12; - declare readonly INVALID_MODIFICATION_ERR: 13; - declare readonly NAMESPACE_ERR: 14; - declare readonly INVALID_ACCESS_ERR: 15; - declare readonly VALIDATION_ERR: 16; - declare readonly TYPE_MISMATCH_ERR: 17; - declare readonly SECURITY_ERR: 18; - declare readonly NETWORK_ERR: 19; - declare readonly ABORT_ERR: 20; - declare readonly URL_MISMATCH_ERR: 21; - declare readonly QUOTA_EXCEEDED_ERR: 22; - declare readonly TIMEOUT_ERR: 23; - declare readonly INVALID_NODE_TYPE_ERR: 24; - declare readonly DATA_CLONE_ERR: 25; - - constructor(message = "", name = "Error") { - super(); - seal(this, new.target, SealedDOMException); - const slots = _(this); - slots.exceptionName = String(name); - slots.exceptionMessage = String(message); - } - - override get name(): string { - return _(this).exceptionName; - } - - override get message(): string { - return _(this).exceptionMessage; - } - - get code(): number { - return DOM_EXCEPTION_CODES.get(_(this).exceptionName) ?? 0; - } -} - -declareSealedRoot(SealedDOMException); - -for (const [index, name] of [ - "INDEX_SIZE_ERR", - "DOMSTRING_SIZE_ERR", - "HIERARCHY_REQUEST_ERR", - "WRONG_DOCUMENT_ERR", - "INVALID_CHARACTER_ERR", - "NO_DATA_ALLOWED_ERR", - "NO_MODIFICATION_ALLOWED_ERR", - "NOT_FOUND_ERR", - "NOT_SUPPORTED_ERR", - "INUSE_ATTRIBUTE_ERR", - "INVALID_STATE_ERR", - "SYNTAX_ERR", - "INVALID_MODIFICATION_ERR", - "NAMESPACE_ERR", - "INVALID_ACCESS_ERR", - "VALIDATION_ERR", - "TYPE_MISMATCH_ERR", - "SECURITY_ERR", - "NETWORK_ERR", - "ABORT_ERR", - "URL_MISMATCH_ERR", - "QUOTA_EXCEEDED_ERR", - "TIMEOUT_ERR", - "INVALID_NODE_TYPE_ERR", - "DATA_CLONE_ERR", -].entries()) { - Object.defineProperty(SealedDOMException.prototype, name, { - value: index + 1, - enumerable: true, - }); -} - -Object.defineProperty(SealedDOMException.prototype, Symbol.toStringTag, { - value: "DOMException", - configurable: true, -}); - -export const DOMException: typeof globalThis.DOMException = - (globalThis as {DOMException?: typeof globalThis.DOMException}) - .DOMException ?? - (SealedDOMException as unknown as typeof globalThis.DOMException); +export const DOMException = globalThis.DOMException; export type DOMException = globalThis.DOMException; @@ -1451,8 +1268,6 @@ function toDictionary(value: unknown, what: string): T { return value as T; } -const kState = Symbol("state"); - // One accessor shared by every event and installed as an own property on // each. The interface declares isTrusted unforgeable, so it cannot live on // the prototype or be redefined. @@ -4399,8 +4214,6 @@ interface Listener { hoverCount?: HoverListenerCount; } -const kGetTheParent = Symbol("get the parent"); - // Every target starts on this one empty list, frozen since nothing may add // to it: most nodes are never listened to, and a list of their own would // be one more allocation each. @@ -4423,7 +4236,7 @@ export interface EventTargetSlots { /** Null until EventTarget target is given an event handler. Most never are. */ handlers: Map | null; - [kGetTheParent]: Step; + _getTheParent: Step; } export interface EventTarget { @@ -4531,7 +4344,7 @@ export class EventTarget } // A bare event target ends the path. A node returns its parent. - [kGetTheParent](_event: Event): EventTarget | null { + _getTheParent(_event: Event): EventTarget | null { return null; } } @@ -4645,11 +4458,6 @@ export function isAbortSignal(value: unknown): value is SealedAbortSignal { ); } -const RuntimeAbortController: - typeof globalThis.AbortController | undefined = globalThis.AbortController; -const RuntimeAbortSignal: typeof globalThis.AbortSignal | undefined = - globalThis.AbortSignal; - const runtimeSignalFollowers = new WeakMap< globalThis.AbortSignal, SealedAbortSignal @@ -4759,15 +4567,11 @@ Object.defineProperty(SealedAbortController.prototype, Symbol.toStringTag, { configurable: true, }); -export const AbortController: typeof globalThis.AbortController = - RuntimeAbortController ?? - (SealedAbortController as unknown as typeof globalThis.AbortController); +export const AbortController = globalThis.AbortController; export type AbortController = globalThis.AbortController; -export const AbortSignal: typeof globalThis.AbortSignal = - RuntimeAbortSignal ?? - (SealedAbortSignal as unknown as typeof globalThis.AbortSignal); +export const AbortSignal = globalThis.AbortSignal; export type AbortSignal = globalThis.AbortSignal; @@ -4802,9 +4606,7 @@ function flattenMore( if (given !== undefined) { if (isAbortSignal(given)) { signal = given; - } else if ( - RuntimeAbortSignal !== undefined && given instanceof RuntimeAbortSignal - ) { + } else if (given instanceof AbortSignal) { signal = followRuntimeSignal(given); } else { throw new TypeError("A listener's signal must be an AbortSignal"); @@ -5484,7 +5286,7 @@ function dispatch(target: EventTarget, event: Event, trusted = true): boolean { if (isActivationEvent && hasActivationBehavior(eventTarget)) { activationTarget = eventTarget; } - let parent = _(eventTarget)[kGetTheParent](event); + let parent = _(eventTarget)._getTheParent(event); while (parent !== null) { if (slottable !== null) { slottable = null; @@ -5536,7 +5338,7 @@ function dispatch(target: EventTarget, event: Event, trusted = true): boolean { ); } if (parent !== null) { - parent = _(parent)[kGetTheParent](event); + parent = _(parent)._getTheParent(event); } slotInClosedTree = false; } @@ -6062,18 +5864,14 @@ function getEventTargetDocument(target: EventTarget): Document | null { return null; } -const kSync = Symbol("resynchronize own properties"); -const kChildrenChangedSteps = Symbol("children changed steps"); -const kAttributeSync = Symbol("resynchronize after an attribute change"); - interface LiveCollection { - [kSync](): void; - [kChildrenChangedSteps]( + _sync(): void; + _childrenChangedSteps( point: Node, changed: readonly Node[] | null, added: boolean, ): void; - [kAttributeSync](element: Element, localName: string): void; + _attributeSync(element: Element, localName: string): void; } // A collection's indexed and named properties are own properties rather @@ -6141,14 +5939,14 @@ function runChildrenChangedSteps( continue; } for (const collection of held) { - _(collection)[kChildrenChangedSteps](point, changed, added); + _(collection)._childrenChangedSteps(point, changed, added); } } } const wide = _(pointSlots.document).extra.documentWideLists; if (wide !== null) { for (const collection of wide) { - _(collection)[kChildrenChangedSteps](point, changed, added); + _(collection)._childrenChangedSteps(point, changed, added); } } } @@ -6161,16 +5959,16 @@ function syncAttributeCollections(element: Element, localName: string): void { const elementSlots = _(element); const map = elementSlots.attributesMap; if (map !== null) { - _(map)[kSync](); + _(map)._sync(); } const classList = elementSlots.classList; if (classList !== null) { - _(classList)[kSync](); + _(classList)._sync(); } const lists = elementSlots.tokenLists; if (lists !== null) { for (const list of lists.values()) { - _(list)[kSync](); + _(list)._sync(); } } if (heldListDocuments.has(elementSlots.document)) { @@ -6182,14 +5980,14 @@ function syncAttributeCollections(element: Element, localName: string): void { continue; } for (const collection of held) { - _(collection)[kAttributeSync](element, localName); + _(collection)._attributeSync(element, localName); } } } const wide = _(elementSlots.document).extra.documentWideLists; if (wide !== null) { for (const collection of wide) { - _(collection)[kAttributeSync](element, localName); + _(collection)._attributeSync(element, localName); } } } @@ -6204,12 +6002,6 @@ const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 0x20; /** A stable per-node serial, so disconnected nodes order consistently. */ let nodeSerial = 0; -const kInsertionSteps = Symbol("insertion steps"); -const kRemovingSteps = Symbol("removing steps"); -const kAdoptingSteps = Symbol("adopting steps"); -const kCloningSteps = Symbol("cloning steps"); -const kCloneSingle = Symbol("clone a single node"); - /** The node-type constants, installed on the prototype below. */ // The slots a class below Node, Element or CharacterData declares of its // own, kept apart so that every record of the family has one shape. @@ -6245,11 +6037,11 @@ export interface NodeSlots extends EventTargetSlots { // What nodeType reports, read here by the engine's hot paths. nodeType: number; registeredObservers: RegisteredObserver[] | null; - [kInsertionSteps]: Step; - [kRemovingSteps]: Step; - [kAdoptingSteps]: Step; - [kCloningSteps]: Step; - [kCloneSingle]: Step; + _insertionSteps: Step; + _removingSteps: Step; + _adoptingSteps: Step; + _cloningSteps: Step; + _cloneSingle: Step; children: HTMLCollection | null; // Set on the first collection a node is asked for by kind and name. collectionCaches?: Map; @@ -6641,25 +6433,22 @@ export class Node extends EventTarget implements globalThis.Node { // An assigned slottable overrides this to return its slot, where the // composed tree continues. - override [kGetTheParent]( - this: Slots, - _event: Event, - ): EventTarget | null { + override _getTheParent(this: Slots, _event: Event): EventTarget | null { return objectOf(this.parent); } // The spec's per-node steps. Subclasses override them; the algorithms // call them. - [kInsertionSteps](): void {} + _insertionSteps(): void {} - [kRemovingSteps](_oldParent: Node): void {} + _removingSteps(_oldParent: Node): void {} - [kAdoptingSteps](_oldDocument: Document): void {} + _adoptingSteps(_oldDocument: Document): void {} - [kCloningSteps](_copy: Node, _document: Document, _deep: boolean): void {} + _cloningSteps(_copy: Node, _document: Document, _deep: boolean): void {} - [kCloneSingle](_document: Document): Node { + _cloneSingle(_document: Document): Node { throw domError("NotSupportedError", "That node cannot be cloned"); } } @@ -7288,7 +7077,7 @@ function insertNode( ) ) { descendantSlots.connected = connected; - descendantSlots[kInsertionSteps](); + descendantSlots._insertionSteps(); if (!connected) { continue; } @@ -7582,7 +7371,7 @@ function removeNode(node: Node, suppressObservers = false): void { const parentWasConnected = _(parent).connected; for (const descendant of shadowIncludingInclusiveDescendants(node)) { _(descendant).connected = false; - _(descendant)[kRemovingSteps](parent); + _(descendant)._removingSteps(parent); if ( parentWasConnected && nodeTypeOf(descendant) === ELEMENT_NODE && @@ -7631,7 +7420,7 @@ function adoptNode(node: Node, document: Document): void { document, ]); } - _(descendant)[kAdoptingSteps](oldDocument); + _(descendant)._adoptingSteps(oldDocument); } } @@ -8250,12 +8039,6 @@ function queueTreeMutationRecord( ); } -const kMembersMoved = Symbol("members moved"); - -const kListMembers = Symbol("listMembers"); -const kListNames = Symbol("listNames"); -const kChangedMembers = Symbol("changedMembers"); - const anyAttribute = Symbol("any attribute"); // Copies an internal base's methods onto an interface's prototype, where @@ -8295,17 +8078,17 @@ interface LiveListSlots { documentWide: boolean; watched: string | symbol | null; names: string[]; - [kListMembers](): Node[]; - [kListNames](items: Node[]): Map | null; - [kChangedMembers](changed: readonly Node[]): Node[] | null; - [kMembersMoved](): void; - [kSync](): void; - [kChildrenChangedSteps]( + _listMembers(): Node[]; + _listNames(items: Node[]): Map | null; + _changedMembers(changed: readonly Node[]): Node[] | null; + _membersMoved(): void; + _sync(): void; + _childrenChangedSteps( point: Node, changed: readonly Node[] | null, added: boolean, ): void; - [kAttributeSync](element: Element, localName: string): void; + _attributeSync(element: Element, localName: string): void; } interface LiveList { @@ -8316,16 +8099,16 @@ interface LiveList { // where a page would see it: shareMethods() copies its methods onto each // list interface. abstract class LiveList implements LiveCollection { - abstract [kListMembers](): Node[]; + abstract _listMembers(): Node[]; - [kListNames](_items: Node[]): Map | null { + _listNames(_items: Node[]): Map | null { return null; } // The members the changed nodes carry, if the collection can determine // that from those nodes alone and knows its named properties did not // move. Null if the list has to be recomputed to find out. - [kChangedMembers]( + _changedMembers( this: Slots, changed: readonly Node[], ): Node[] | null { @@ -8344,9 +8127,9 @@ abstract class LiveList implements LiveCollection { // The list cannot tell its members moved, because a splice moves them // within the one array the collection holds. - [kMembersMoved](): void {} + _membersMoved(): void {} - [kSync](this: Slots): void { + _sync(this: Slots): void { const slots = this; if (slots.registered === null) { return; @@ -8367,7 +8150,7 @@ abstract class LiveList implements LiveCollection { // except for a document-wide list, which is dropped and recomputed on the // next read, because a document sees far more changes than reads of such // a list. - [kChildrenChangedSteps]( + _childrenChangedSteps( this: Slots, point: Node, changed: readonly Node[] | null, @@ -8386,7 +8169,7 @@ abstract class LiveList implements LiveCollection { return; } if (changed !== null) { - const members = slots[kChangedMembers](changed); + const members = slots._changedMembers(changed); if ( members !== null && splice(ownerOf(this), point, changed, members, added) @@ -8399,7 +8182,7 @@ abstract class LiveList implements LiveCollection { } // A list that reads none of the element's attributes is unaffected. - [kAttributeSync]( + _attributeSync( this: Slots, _element: Element, localName: string, @@ -8407,7 +8190,7 @@ abstract class LiveList implements LiveCollection { const slots = this; const watched = slots.watched; if (watched === localName || watched === anyAttribute) { - slots[kSync](); + slots._sync(); } } } @@ -8449,14 +8232,14 @@ function dropList(list: LiveList): void { return; } listSlots.exact = false; - listSlots[kMembersMoved](); + listSlots._membersMoved(); } function recomputeList(list: LiveList): void { const listSlots = _(list); - listSlots.items = listSlots[kListMembers](); + listSlots.items = listSlots._listMembers(); listSlots.exact = true; - listSlots[kMembersMoved](); + listSlots._membersMoved(); defineListProperties(list); } @@ -8515,7 +8298,7 @@ function splice( } items.splice(at, members.length); } - listSlots[kMembersMoved](); + listSlots._membersMoved(); defineIndices(list, items.length); return true; } @@ -8551,7 +8334,7 @@ function defineListProperties(list: LiveList): void { delete record[name]; } listSlots.names = []; - const named = listSlots[kListNames](items); + const named = listSlots._listNames(items); if (named !== null) { for (const [name, node] of named) { if (name === "" || Object.prototype.hasOwnProperty.call(list, name)) { @@ -8612,7 +8395,7 @@ function ensureList(list: LiveList): Node[] { export interface NodeListSlots extends LiveListSlots { compute: () => Node[]; - [kListMembers]: Step; + _listMembers: Step; } export interface NodeList extends LiveList { @@ -8657,7 +8440,7 @@ export class NodeList { return at < items.length ? (items[at] as unknown as globalThis.Node) : null; } - [kListMembers](this: Slots): Node[] { + _listMembers(this: Slots): Node[] { return this.compute(); } } @@ -8693,11 +8476,11 @@ interface HTMLCollectionSlots extends LiveListSlots { root?: Node; matches?: (element: Element) => boolean; members?: Set | null; - [kListMembers]: Step; - [kChangedMembers]: Step; - [kListNames]: Step; - [kAttributeSync]: Step; - [kMembersMoved]: Step; + _listMembers: Step; + _changedMembers: Step; + _listNames: Step; + _attributeSync: Step; + _membersMoved: Step; } interface HTMLCollection extends LiveList { @@ -8750,11 +8533,11 @@ class HTMLCollection { return null; } - [kListMembers](this: Slots): Node[] { + _listMembers(this: Slots): Node[] { return this.compute(); } - [kChangedMembers]( + _changedMembers( this: Slots, changed: readonly Node[], ): Node[] | null { @@ -8762,14 +8545,14 @@ class HTMLCollection { if (matches !== undefined) { return matchingChangedMembers(ownerOf(this), matches, changed); } - const members = LiveList.prototype[kChangedMembers].call(this, changed); + const members = LiveList.prototype._changedMembers.call(this, changed); if (members === null || isNameless(members)) { return members; } return null; } - [kListNames](items: Node[]): Map { + _listNames(items: Node[]): Map { const named = new Map(); for (const item of items) { const element = item as Element; @@ -8791,7 +8574,7 @@ class HTMLCollection { // change to either moves its named properties. No collection here selects // members by id or name, so the members stay and only the names are // rebuilt. - [kAttributeSync]( + _attributeSync( this: Slots, element: Element, localName: string, @@ -8803,7 +8586,7 @@ class HTMLCollection { return; } if (localName !== "id" && localName !== "name") { - LiveList.prototype[kAttributeSync].call(this, element, localName); + LiveList.prototype._attributeSync.call(this, element, localName); return; } if ( @@ -8818,7 +8601,7 @@ class HTMLCollection { } } - [kMembersMoved](this: Slots): void { + _membersMoved(this: Slots): void { const slots = this; if (slots.members !== undefined) { slots.members = null; @@ -8988,7 +8771,7 @@ function matchingAttributeSync( return; } } - collectionSlots[kSync](); + collectionSlots._sync(); } function getDescendantElements(root: Node, into: Element[]): Element[] { @@ -9146,15 +8929,13 @@ function toASCIIUppercase(value: string): string { return value.toUpperCase(); } -const kTokens = Symbol("tokens"); - interface DOMTokenListSlots extends LiveListSlots { element: Element; attribute: string; supported: Set | null; - [kTokens]: Step; - [kChangedMembers]: Step; - [kListMembers]: Step; + _tokens: Step; + _changedMembers: Step; + _listMembers: Step; } interface DOMTokenList extends LiveList { @@ -9182,7 +8963,7 @@ class DOMTokenList implements globalThis.DOMTokenList { } get length(): number { - return _(this)[kTokens].length; + return _(this)._tokens.length; } get value(): string { @@ -9195,25 +8976,25 @@ class DOMTokenList implements globalThis.DOMTokenList { setAttributeOf(slots.element, slots.attribute, String(value)); } - get [kTokens](): string[] { + get _tokens(): string[] { return ensureList( ownerOf(this as unknown as Slots), ) as unknown as string[]; } item(index: number): string | null { - const tokens = _(this)[kTokens]; + const tokens = _(this)._tokens; const at = toUnsignedLong(index); return at < tokens.length ? tokens[at] : null; } contains(token: string): boolean { - return _(this)[kTokens].includes(String(token)); + return _(this)._tokens.includes(String(token)); } add(...tokens: string[]): void { validateTokens(tokens); - const current = _(this)[kTokens].slice(); + const current = _(this)._tokens.slice(); for (const token of tokens) { if (!current.includes(token)) { current.push(String(token)); @@ -9224,7 +9005,7 @@ class DOMTokenList implements globalThis.DOMTokenList { remove(...tokens: string[]): void { validateTokens(tokens); - const current = _(this)[kTokens].filter( + const current = _(this)._tokens.filter( (each) => !tokens.some((token) => String(token) === each), ); writeTokenList(this, current); @@ -9233,7 +9014,7 @@ class DOMTokenList implements globalThis.DOMTokenList { toggle(token: string, force?: boolean): boolean { validateTokens([token]); const name = String(token); - const current = _(this)[kTokens].slice(); + const current = _(this)._tokens.slice(); const index = current.indexOf(name); if (index !== -1) { if (force === undefined || force === false) { @@ -9255,7 +9036,7 @@ class DOMTokenList implements globalThis.DOMTokenList { validateTokens([token, newToken]); const name = String(token); const replacement = String(newToken); - const current = _(this)[kTokens].slice(); + const current = _(this)._tokens.slice(); if (!current.includes(name)) { return false; } @@ -9292,11 +9073,11 @@ class DOMTokenList implements globalThis.DOMTokenList { } // An attribute's tokens are not part of the tree's shape. - [kChangedMembers](): Node[] { + _changedMembers(): Node[] { return []; } - [kListMembers](this: Slots): Node[] { + _listMembers(this: Slots): Node[] { const slots = this; const value = attributeOf(slots.element, slots.attribute); const tokens = value === null ? [] : splitOnASCIIWhitespace(value); @@ -9597,15 +9378,12 @@ export class Text extends CharacterData implements globalThis.Text { return created; } - override [kGetTheParent]( - this: Slots, - _event: Event, - ): EventTarget | null { + override _getTheParent(this: Slots, _event: Event): EventTarget | null { const slots = this; return slots.assignedSlot ?? objectOf(slots.parent); } - override [kCloneSingle](this: Slots, document: Document): Node { + override _cloneSingle(this: Slots, document: Document): Node { const copy = new Text(this.data); _(copy).document = document; return copy; @@ -9635,7 +9413,7 @@ class CDATASection extends Text { return "#cdata-section"; } - override [kCloneSingle](this: Slots, document: Document): Node { + override _cloneSingle(this: Slots, document: Document): Node { const copy = new CDATASection(this.data); _(copy).document = document; return copy; @@ -9668,7 +9446,7 @@ export class Comment extends CharacterData implements globalThis.Comment { return "#comment"; } - override [kCloneSingle](this: Slots, document: Document): Node { + override _cloneSingle(this: Slots, document: Document): Node { const copy = new Comment(this.data); _(copy).document = document; return copy; @@ -9716,7 +9494,7 @@ class ProcessingInstruction extends CharacterData { return _(this).extra.target; } - override [kCloneSingle]( + override _cloneSingle( this: Slots, document: Document, ): Node { @@ -9784,7 +9562,7 @@ class DocumentType extends Node { return _(this).extra.name; } - override [kCloneSingle](this: Slots, document: Document): Node { + override _cloneSingle(this: Slots, document: Document): Node { const slots = this; const copy = new DocumentType( slots.extra.name, @@ -9868,7 +9646,7 @@ export class DocumentFragment return null; } - override [kCloneSingle](document: Document): Node { + override _cloneSingle(document: Document): Node { const copy = new DocumentFragment(); _(copy).document = document; return copy; @@ -9907,8 +9685,6 @@ function setDescendantText(node: Node, value: string | null): void { export type AttributeSlots = Slots; -const kQualifiedName = Symbol("qualified name"); - interface AttrExtra extends NodeExtra { namespace: string | null; prefix: string | null; @@ -9919,7 +9695,7 @@ interface AttrExtra extends NodeExtra { interface AttrSlots extends NodeSlots { extra: AttrExtra; - [kQualifiedName]: Step; + _qualifiedName: Step; } interface Attr { @@ -9957,7 +9733,7 @@ class Attr extends Node implements globalThis.Attr { } get name(): string { - return _(this)[kQualifiedName]; + return _(this)._qualifiedName; } get value(): string { @@ -9981,7 +9757,7 @@ class Attr extends Node implements globalThis.Attr { } override get nodeName(): string { - return _(this)[kQualifiedName]; + return _(this)._qualifiedName; } override get nodeValue(): string | null { @@ -10000,14 +9776,14 @@ class Attr extends Node implements globalThis.Attr { setExistingAttributeValue(this, nullableString(value)); } - get [kQualifiedName](): string { + get _qualifiedName(): string { const slots = this as unknown as Slots; return slots.extra.prefix === null ? slots.extra.localName : `${slots.extra.prefix}:${slots.extra.localName}`; } - override [kCloneSingle](this: Slots, document: Document): Node { + override _cloneSingle(this: Slots, document: Document): Node { const slots = this; const copy = new Attr( slots.extra.namespace, @@ -10044,15 +9820,13 @@ function notifyAttributeChange(element: Element, localName: string): void { styleAttributeChanged(element, localName); } -const kAttributeChangeSteps = Symbol("attribute change steps"); - function changeAttribute(attribute: Attr, value: string): void { const attributeSlots = _(attribute); const element = attributeSlots.extra.ownerElement as Element; const oldValue = attributeSlots.extra.value; queueAttributeMutationRecord(element, attribute, oldValue); attributeSlots.extra.value = value; - _(element)[kAttributeChangeSteps]( + _(element)._attributeChangeSteps( attributeSlots.extra.localName, oldValue, value, @@ -10071,7 +9845,7 @@ function appendAttribute(element: Element, attribute: Attr): void { const attributeSlots = _(attribute); attributeSlots.extra.ownerElement = element; attributeSlots.document = elementSlots.document; - elementSlots[kAttributeChangeSteps]( + elementSlots._attributeChangeSteps( attributeSlots.extra.localName, null, attributeSlots.extra.value, @@ -10092,7 +9866,7 @@ function removeAttributeNode(element: Element, attribute: Attr): void { list.splice(index, 1); } attributeSlots.extra.ownerElement = null; - elementSlots[kAttributeChangeSteps]( + elementSlots._attributeChangeSteps( attributeSlots.extra.localName, oldValue, null, @@ -10120,7 +9894,7 @@ function replaceAttribute( newAttributeSlots.extra.ownerElement = element; newAttributeSlots.document = elementSlots.document; oldAttributeSlots.extra.ownerElement = null; - elementSlots[kAttributeChangeSteps]( + elementSlots._attributeChangeSteps( newAttributeSlots.extra.localName, oldAttributeSlots.extra.value, newAttributeSlots.extra.value, @@ -10162,7 +9936,7 @@ function getAttributeByName( name = toASCIILowercase(name); } for (const attribute of elementSlots.attributeList) { - if (attribute[kQualifiedName] === name) { + if (attribute._qualifiedName === name) { return objectOf(attribute); } } @@ -10215,9 +9989,9 @@ function setAttributeNode(element: Element, attribute: Attr): Attr | null { interface NamedNodeMapSlots extends LiveListSlots { element: Element; - [kChangedMembers]: Step; - [kListMembers]: Step; - [kListNames]: Step; + _changedMembers: Step; + _listMembers: Step; + _listNames: Step; } interface NamedNodeMap extends LiveList { @@ -10288,17 +10062,17 @@ class NamedNodeMap implements globalThis.NamedNodeMap { } // An element's attributes are not part of the tree's shape. - [kChangedMembers](): Node[] { + _changedMembers(): Node[] { return []; } - [kListMembers](this: Slots): Node[] { + _listMembers(this: Slots): Node[] { return _(this.element).attributeList.map((attribute) => objectOf(attribute), ); } - [kListNames](this: Slots, items: Node[]): Map { + _listNames(this: Slots, items: Node[]): Map { const named = new Map(); const slots = this; const html = @@ -10306,7 +10080,7 @@ class NamedNodeMap implements globalThis.NamedNodeMap { isHTMLDocument(_(slots.element).document); for (const item of items) { const attribute = item as Attr; - const name = _(attribute)[kQualifiedName]; + const name = _(attribute)._qualifiedName; if (html && toASCIILowercase(name) !== name) { continue; } @@ -10498,8 +10272,8 @@ export interface ElementSlots extends NodeSlots { pseudoElements: Map | null; pseudoHost: Element | null; pseudoName: string | null; - [kQualifiedName]: Step; - [kAttributeChangeSteps]: Step; + _qualifiedName: Step; + _attributeChangeSteps: Step; // Set on the first selectionchange a text control schedules. textSelectionChangeScheduled?: boolean; } @@ -10644,11 +10418,11 @@ export class Element extends Node implements globalThis.Element { get tagName(): string { const slots = _(this); if (slots.namespace !== HTML_NAMESPACE || !isHTMLDocument(slots.document)) { - return slots[kQualifiedName]; + return slots._qualifiedName; } let upper = slots.upperName; if (upper === null) { - upper = toASCIIUppercase(slots[kQualifiedName]); + upper = toASCIIUppercase(slots._qualifiedName); slots.upperName = upper; } return upper; @@ -10767,7 +10541,7 @@ export class Element extends Node implements globalThis.Element { return 1; } - get [kQualifiedName](): string { + get _qualifiedName(): string { const slots = this; return slots.prefix === null ? slots.localName @@ -10894,7 +10668,7 @@ export class Element extends Node implements globalThis.Element { } getAttributeNames(): string[] { - return _(this).attributeList.map((attribute) => attribute[kQualifiedName]); + return _(this).attributeList.map((attribute) => attribute._qualifiedName); } getAttribute(qualifiedName: string): string | null { @@ -10966,7 +10740,7 @@ export class Element extends Node implements globalThis.Element { name = toASCIILowercase(name); } for (const attribute of slots.attributeList) { - if (attribute[kQualifiedName] === name) { + if (attribute._qualifiedName === name) { return true; } } @@ -11243,7 +11017,7 @@ export class Element extends Node implements globalThis.Element { } /** An assigned slottable returns its slot instead of its parent. */ - override [kGetTheParent]( + override _getTheParent( this: Slots, _event: Event, ): EventTarget | null { @@ -11251,7 +11025,7 @@ export class Element extends Node implements globalThis.Element { return slots.assignedSlot ?? objectOf(slots.parent); } - override [kInsertionSteps](this: Slots): void { + override _insertionSteps(this: Slots): void { const root = getRoot(ownerOf(this)); if (nodeTypeOf(root) === DOCUMENT_NODE) { addToIdMap(root as Document, ownerOf(this)); @@ -11277,7 +11051,7 @@ export class Element extends Node implements globalThis.Element { } } - override [kRemovingSteps](this: Slots, oldParent: Node): void { + override _removingSteps(this: Slots, oldParent: Node): void { const root = getRoot(oldParent); if (nodeTypeOf(root) === DOCUMENT_NODE) { removeFromIdMap(root as Document, ownerOf(this)); @@ -11294,7 +11068,7 @@ export class Element extends Node implements globalThis.Element { } } - [kAttributeChangeSteps]( + _attributeChangeSteps( this: Slots, localName: string, oldValue: string | null, @@ -11373,7 +11147,7 @@ export class Element extends Node implements globalThis.Element { syncUAShadowTree(ownerOf(this)); } - override [kCloneSingle](this: Slots, document: Document): Node { + override _cloneSingle(this: Slots, document: Document): Node { const slots = this; const copy = createElementInternal( document, @@ -12508,14 +12282,14 @@ export class HTMLElement extends Element { // was showing as, so it closes. It closes silently, because the author // who changed the attribute is not asking to be told about the old // popover. - override [kAttributeChangeSteps]( + override _attributeChangeSteps( this: Slots, localName: string, oldValue: string | null, value: string | null, namespace: string | null, ): void { - superStep(HTMLElement, kAttributeChangeSteps).call( + superStep(HTMLElement, "_attributeChangeSteps").call( this, localName, oldValue, @@ -12536,8 +12310,8 @@ export class HTMLElement extends Element { // The top layer holds nothing that is off the tree, and there is no page // for the popover to be above anymore. - override [kRemovingSteps](this: Slots, oldParent: Node): void { - superStep(HTMLElement, kRemovingSteps).call(this, oldParent); + override _removingSteps(this: Slots, oldParent: Node): void { + superStep(HTMLElement, "_removingSteps").call(this, oldParent); if (!isShowingPopover(ownerOf(this))) { return; } @@ -14598,7 +14372,7 @@ export class ShadowRoot // Dispatch leaves a shadow tree through the host, unless the event was // dispatched inside this tree and is not composed. - override [kGetTheParent]( + override _getTheParent( this: Slots, event: Event, ): EventTarget | null { @@ -14614,7 +14388,7 @@ export class ShadowRoot return this.extra.host; } - override [kCloneSingle](_document: Document): Node { + override _cloneSingle(_document: Document): Node { throw domError("NotSupportedError", "A shadow root cannot be cloned"); } } @@ -15132,7 +14906,7 @@ class HTMLTemplateElement extends HTMLElement { toggleAttributeOf(this, "shadowrootserializable", Boolean(value)); } - override [kAdoptingSteps]( + override _adoptingSteps( this: Slots, _oldDocument: Document, ): void { @@ -15143,7 +14917,7 @@ class HTMLTemplateElement extends HTMLElement { } } - override [kCloningSteps]( + override _cloningSteps( this: Slots, copy: Node, document: Document, @@ -16076,14 +15850,14 @@ class HTMLCanvasElement extends HTMLElement { return noMediaPipeline("bitmap"); } - override [kAttributeChangeSteps]( + override _attributeChangeSteps( this: Slots, localName: string, oldValue: string | null, value: string | null, namespace: string | null, ): void { - superStep(HTMLCanvasElement, kAttributeChangeSteps).call( + superStep(HTMLCanvasElement, "_attributeChangeSteps").call( this, localName, oldValue, @@ -16286,8 +16060,8 @@ interface HTMLDetailsElementExtra extends NodeExtra { interface HTMLDetailsElementSlots extends HTMLElementSlots { extra: HTMLDetailsElementExtra; - [kEnsureUAShadowTree]: Step; - [kSyncUAShadowTree]: Step; + _ensureUAShadowTree: Step; + _syncUAShadowTree: Step; } interface HTMLDetailsElement { @@ -16306,7 +16080,7 @@ class HTMLDetailsElement extends HTMLElement { slots.extra.content = null; } - [kEnsureUAShadowTree]?(this: Slots): void { + _ensureUAShadowTree?(this: Slots): void { if (this.extra.upgraded) { syncUAShadowTree(ownerOf(this)); return; @@ -16336,7 +16110,7 @@ class HTMLDetailsElement extends HTMLElement { syncUAShadowTree(ownerOf(this)); } - [kSyncUAShadowTree]?(this: Slots): void { + _syncUAShadowTree?(this: Slots): void { const content = this.extra.content; if (content === null) { return; @@ -16347,14 +16121,14 @@ class HTMLDetailsElement extends HTMLElement { } } - override [kAttributeChangeSteps]( + override _attributeChangeSteps( this: Slots, localName: string, oldValue: string | null, value: string | null, namespace: string | null, ): void { - superStep(HTMLDetailsElement, kAttributeChangeSteps).call( + superStep(HTMLDetailsElement, "_attributeChangeSteps").call( this, localName, oldValue, @@ -16593,25 +16367,25 @@ class HTMLDialogElement extends HTMLElement { // Nothing off the tree can render above the document, and a detached // dialog is no longer modal. - override [kRemovingSteps]( + override _removingSteps( this: Slots, oldParent: Node, ): void { - superStep(HTMLDialogElement, kRemovingSteps).call(this, oldParent); + superStep(HTMLDialogElement, "_removingSteps").call(this, oldParent); getTopLayer(this.document).delete(ownerOf(this)); } // Removing `open` by hand ends a modal dialog's modality too: it leaves // the top layer, and the page behind it is no longer inert (HTML's // dialog attribute change steps). - override [kAttributeChangeSteps]( + override _attributeChangeSteps( this: Slots, localName: string, oldValue: string | null, value: string | null, namespace: string | null, ): void { - superStep(HTMLDialogElement, kAttributeChangeSteps).call( + superStep(HTMLDialogElement, "_attributeChangeSteps").call( this, localName, oldValue, @@ -17137,14 +16911,12 @@ function implicitlySubmit(input: HTMLInputElement): void { } } -const kResetControl = Symbol("put a control back to its default"); - function resetControl(control: Element): void { editedSinceChange.delete(control); setUserValidity(control, false); - const resettable = control as unknown as Record void>; - if (typeof _(resettable)[kResetControl] === "function") { - _(resettable)[kResetControl]!(); + const slots = _(control) as unknown as {_resetControl?: () => void}; + if (typeof slots._resetControl === "function") { + slots._resetControl(); } else if (isFormAssociatedCustom(control)) { enqueueCallbackReaction(control, "formResetCallback", []); } @@ -17230,124 +17002,14 @@ function createEntry( return [key, file]; } -const kEntryList = Symbol("the entry list"); - -interface EntryListFormData { - [kEntryList]: FormDataEntry[]; -} - -/** - * The entry list a runtime without its own FormData falls back to. Every - * runtime this engine supports has one, and its storage is what `fetch` - * reads, so it is the base below whenever it exists. - */ -class EntryListFormData { - constructor() { - this[kEntryList] = []; - } - - append(name: string, value: string | Blob, filename?: string): void { - this[kEntryList].push(createEntry(name, value, filename)); - } - - delete(name: string): void { - const wanted = toScalarValueString(String(name)); - this[kEntryList] = this[kEntryList].filter(([key]) => key !== wanted); - } - - get(name: string): globalThis.FormDataEntryValue | null { - const wanted = toScalarValueString(String(name)); - const found = this[kEntryList].find(([key]) => key === wanted); - return found === undefined ? null : found[1]; - } - - getAll(name: string): globalThis.FormDataEntryValue[] { - const wanted = toScalarValueString(String(name)); - return this[kEntryList] - .filter(([key]) => key === wanted).map(([, value]) => value); - } - - has(name: string): boolean { - const wanted = toScalarValueString(String(name)); - return this[kEntryList].some(([key]) => key === wanted); - } - - set(name: string, value: string | Blob, filename?: string): void { - const entry = createEntry(name, value, filename); - const entries = this[kEntryList]; - const at = entries.findIndex(([key]) => key === entry[0]); - if (at === -1) { - entries.push(entry); - return; - } - entries[at] = entry; - this[kEntryList] = entries.filter( - (held, index) => index === at || held[0] !== entry[0], - ); - } - - forEach( - callbackfn: ( - value: globalThis.FormDataEntryValue, - key: string, - parent: any, - ) => void, - thisArg?: any, - ): void { - for (let index = 0; index < this[kEntryList].length; index++) { - const [key, value] = this[kEntryList][index]; - callbackfn.call(thisArg, value, key, this); - } - } - - entries(): FormDataIterator<[string, globalThis.FormDataEntryValue]> { - return this[kEntryList] - .map(([key, value]): [string, globalThis.FormDataEntryValue] => - [key, value])[Symbol.iterator](); - } - - keys(): FormDataIterator { - return this[kEntryList].map(([key]) => key)[Symbol.iterator](); - } - - values(): FormDataIterator { - return this[kEntryList].map( - ([, value]): globalThis.FormDataEntryValue => value, - )[Symbol.iterator](); - } - - [Symbol.iterator](): FormDataIterator< - [string, globalThis.FormDataEntryValue] - > { - return this.entries(); - } -} - -const FormDataBase: new () => EntryListFormData = - typeof (globalThis as { - FormData?: unknown; - }).FormData === "function" - ? (globalThis as unknown as {FormData: new () => EntryListFormData}) - .FormData - : EntryListFormData; - -// A runtime stores a file under the filename its caller gave, or, in at -// least one, under the name the file already had. The wire is right -// either way; only reading an entry back differs, so the filenames are -// kept here when the runtime reports the wrong one. +// A runtime stores a file under the filename its caller gave, or, as Bun +// does, under the name the file already had. The wire is right either +// way; only reading an entry back differs, so the filenames are kept here +// when the runtime reports the wrong one. const runtimeKeepsFilenames = ((): boolean => { - const Runtime = (globalThis as {FormData?: unknown}).FormData; - if (typeof Runtime !== "function") { - return true; - } - try { - const probe = new (Runtime as new () => globalThis.FormData)(); - probe.append("k", new File(["x"], "given"), "wanted"); - return (probe.get("k") as globalThis.File).name === "wanted"; - } catch (error) { - void error; - return true; - } + const probe = new globalThis.FormData(); + probe.append("k", new File(["x"], "given"), "wanted"); + return (probe.get("k") as globalThis.File).name === "wanted"; })(); // One File per stored blob and name, so reading an entry twice gives one @@ -17385,7 +17047,7 @@ interface FormData { readonly [internals]: FormDataSlots; } -class FormData extends FormDataBase { +class FormData extends globalThis.FormData { constructor(form?: HTMLFormElement, submitter?: HTMLElement | null) { super(); seal(this, new.target, FormData); @@ -17866,7 +17528,7 @@ function constructEntryList( class HTMLFormControlsCollection extends (HTMLCollection as new ( ...args: ConstructorParameters - ) => Omit) { + ) => Omit) { constructor(compute: () => Element[], owner: Node | null = null) { // The form attribute can associate a control anywhere in the tree, and // what counts as a control depends on its attributes, so the list is @@ -17894,7 +17556,7 @@ class HTMLFormControlsCollection ) as unknown as Element; } - [kListNames]( + _listNames( this: Slots, items: Node[], ): Map { @@ -18179,8 +17841,8 @@ class HTMLIFrameElement extends HTMLElement { return null; } - override [kInsertionSteps](this: Slots): void { - superStep(HTMLIFrameElement, kInsertionSteps).call(this); + override _insertionSteps(this: Slots): void { + superStep(HTMLIFrameElement, "_insertionSteps").call(this); if (!ownerOf(this).isConnected) { return; } @@ -18197,11 +17859,8 @@ class HTMLIFrameElement extends HTMLElement { }, 0); } - override [kRemovingSteps]( - this: Slots, - parent: Node, - ): void { - superStep(HTMLIFrameElement, kRemovingSteps).call(this, parent); + override _removingSteps(this: Slots, parent: Node): void { + superStep(HTMLIFrameElement, "_removingSteps").call(this, parent); const slots = this; slots.extra.contentDocument = null; slots.extra.contentWindow = null; @@ -18308,7 +17967,7 @@ interface HTMLImageElementExtra extends NodeExtra { interface HTMLImageElementSlots extends HTMLElementSlots { extra: HTMLImageElementExtra; - [kEnsureUAShadowTree]: Step; + _ensureUAShadowTree: Step; } interface HTMLImageElement { @@ -18451,7 +18110,7 @@ class HTMLImageElement extends HTMLElement { // as an inline element would, unless HTML's rendering rules keep it a // box. Its UA shadow tree holds the text, built the first time it can // be needed. - [kEnsureUAShadowTree]?(this: Slots): void { + _ensureUAShadowTree?(this: Slots): void { const alt = attributeOf(ownerOf(this), "alt") ?? ""; const slots = this; const text = slots.extra.altText; @@ -18473,18 +18132,18 @@ class HTMLImageElement extends HTMLElement { appendChildOf(root, slots.extra.altText as unknown as globalThis.Text); } - override [kInsertionSteps](this: Slots): void { - superStep(HTMLImageElement, kInsertionSteps).call(this); + override _insertionSteps(this: Slots): void { + superStep(HTMLImageElement, "_insertionSteps").call(this); if (this.extra.imageState.pending) { updateImageData(ownerOf(this)); } } - override [kAdoptingSteps]( + override _adoptingSteps( this: Slots, oldDocument: Document, ): void { - superStep(HTMLImageElement, kAdoptingSteps).call(this, oldDocument); + superStep(HTMLImageElement, "_adoptingSteps").call(this, oldDocument); // The fragment parser builds a