From d55b2fadd0935369ff7c0d6ae5f71e74034da090 Mon Sep 17 00:00:00 2001 From: Daniel Jacobs Date: Tue, 9 Dec 2025 15:09:19 -0500 Subject: [PATCH 1/3] web: Support named access on document for polyfill elements --- .../core/src/internal/player/inner.tsx | 113 ++++++++++++++++++ .../internal/player/ruffle-embed-element.ts | 50 +++++++- .../internal/player/ruffle-object-element.ts | 68 ++++++++++- .../polyfill/document_accessors/expected.html | 18 +++ .../polyfill/document_accessors/index.html | 17 +++ .../test/polyfill/document_accessors/test.ts | 111 +++++++++++++++++ 6 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 web/packages/selfhosted/test/polyfill/document_accessors/expected.html create mode 100644 web/packages/selfhosted/test/polyfill/document_accessors/index.html create mode 100644 web/packages/selfhosted/test/polyfill/document_accessors/test.ts diff --git a/web/packages/core/src/internal/player/inner.tsx b/web/packages/core/src/internal/player/inner.tsx index a5848340ac55..747fc0f99d91 100644 --- a/web/packages/core/src/internal/player/inner.tsx +++ b/web/packages/core/src/internal/player/inner.tsx @@ -2558,3 +2558,116 @@ function detectBrowserDirection(): string { return "ltr"; } + +type NamedAccessValue = HTMLElement | HTMLCollectionOf; + +/** + * + * @param v The value to check if is an HTMLCollectionOf + * + * @returns If the value is a collection + */ +function isCollection( + v: NamedAccessValue | undefined, +): v is HTMLCollectionOf { + return !!v && "item" in v && "length" in v; +} + +/** + * + * @param items The items to convert to an HTMLCollectionOf + * + * @returns An HTMLCollectionOf + */ +function makeCollection(items: HTMLElement[]): HTMLCollectionOf { + return { + length: items.length, + item: (i: number) => items[i] ?? null, + namedItem: (n: string) => + items.find((e) => e.getAttribute("name") === n) ?? null, + [Symbol.iterator]: function* () { + yield* items; + }, + } as unknown as HTMLCollectionOf; +} + +const docMap = document as unknown as Record; + +/** + * Clear a property on document based on embed/object name + * + * @param el The embed/object element + * @param name The name for which to remove the property from document + */ +export function clearDocumentNamedAccessor( + el: HTMLElement, + name: string | undefined | null, +) { + if (!name) { + return; + } + const existing = docMap[name]; + + if ( + existing instanceof HTMLCollection || + (existing instanceof HTMLElement && + (existing.tagName === "EMBED" || existing.tagName === "OBJECT")) + ) { + return; + } + + // Simple case: single element + if (existing === el) { + Reflect.deleteProperty(document, name); + return; + } + if (isCollection(existing)) { + const items = [...existing].filter((x) => x !== el); + if (items.length === 0) { + Reflect.deleteProperty(document, name); + } else if (items.length === 1 && items[0]) { + docMap[name] = items[0]; + } else { + docMap[name] = makeCollection(items); + } + } +} + +/** + * Define a property on document based on embed/object name + * + * @param el The embed/object element + * @param name The name for which to define the property on document + */ +export function defineDocumentNamedAccessor( + el: HTMLElement, + name: string | null | undefined, +) { + if (!name) { + return; + } + + const existing = docMap[name]; + + if ( + existing instanceof HTMLCollection || + (existing instanceof HTMLElement && + (existing.tagName === "EMBED" || existing.tagName === "OBJECT")) + ) { + return; + } + + if (!existing) { + docMap[name] = el; + return; + } + + const items = isCollection(existing) + ? [...existing].filter((x) => x !== el) + : existing === el + ? [] + : [existing]; + + items.push(el); + docMap[name] = makeCollection(items); +} diff --git a/web/packages/core/src/internal/player/ruffle-embed-element.ts b/web/packages/core/src/internal/player/ruffle-embed-element.ts index f863ed043e01..08f8eee85a1f 100644 --- a/web/packages/core/src/internal/player/ruffle-embed-element.ts +++ b/web/packages/core/src/internal/player/ruffle-embed-element.ts @@ -4,6 +4,8 @@ import { isFallbackElement, isYoutubeFlashSource, workaroundYoutubeMixedContent, + clearDocumentNamedAccessor, + defineDocumentNamedAccessor, } from "./inner"; import { registerElement } from "../register-element"; import { isSwf } from "../../swf-utils"; @@ -17,6 +19,7 @@ import { isSwf } from "../../swf-utils"; * @internal */ export class RuffleEmbedElement extends RufflePlayerElement { + private _name: string = ""; /** * @ignore * @internal @@ -31,10 +34,23 @@ export class RuffleEmbedElement extends RufflePlayerElement { const options = getPolyfillOptions(src.value, getOptionString); // Kick off the SWF download. - this.load(options, true); + this.load(options, true).then(() => { + const name = this.attributes.getNamedItem("name")?.value; + defineDocumentNamedAccessor(this, name); + }); } } + /** + * @ignore + * @internal + */ + override disconnectedCallback(): void { + const name = this.attributes.getNamedItem("name")?.value; + clearDocumentNamedAccessor(this, name); + super.disconnectedCallback(); + } + /** * Polyfill of HTMLEmbedElement. * @@ -76,7 +92,7 @@ export class RuffleEmbedElement extends RufflePlayerElement { * @internal */ static override get observedAttributes(): string[] { - return [...RufflePlayerElement.observedAttributes, "src"]; + return [...RufflePlayerElement.observedAttributes, "src", "name"]; } /** @@ -89,6 +105,10 @@ export class RuffleEmbedElement extends RufflePlayerElement { newValue: string | undefined, ): void { super.attributeChangedCallback(name, oldValue, newValue); + if (name === "name" && oldValue !== newValue) { + this.name = newValue ?? ""; + return; + } if (this.isConnected && name === "src") { const src = this.attributes.getNamedItem("src"); if (src) { @@ -208,4 +228,30 @@ export class RuffleEmbedElement extends RufflePlayerElement { set type(typeVal: string) { this.setAttribute("type", typeVal); } + + /** + * Polyfill of name getter + * + * @ignore + * @internal + */ + get name(): string { + return this._name ?? ""; + } + + /** + * Polyfill of name setter + * + * @ignore + * @internal + */ + set name(name: string) { + const oldName = this._name; + this._name = name; + if (oldName !== name) { + clearDocumentNamedAccessor(this, oldName); + defineDocumentNamedAccessor(this, name); + } + this.setAttribute("name", name); + } } diff --git a/web/packages/core/src/internal/player/ruffle-object-element.ts b/web/packages/core/src/internal/player/ruffle-object-element.ts index 1caf0588d485..72665b036596 100644 --- a/web/packages/core/src/internal/player/ruffle-object-element.ts +++ b/web/packages/core/src/internal/player/ruffle-object-element.ts @@ -4,6 +4,8 @@ import { isFallbackElement, isYoutubeFlashSource, workaroundYoutubeMixedContent, + clearDocumentNamedAccessor, + defineDocumentNamedAccessor, } from "./inner"; import { FLASH_ACTIVEX_CLASSID } from "../../flash-identifiers"; import { registerElement } from "../register-element"; @@ -65,6 +67,7 @@ function paramsOf(elem: Element): Record { */ export class RuffleObjectElement extends RufflePlayerElement { private params: Record = {}; + private _name: string = ""; /** * @ignore @@ -101,10 +104,23 @@ export class RuffleObjectElement extends RufflePlayerElement { const options = getPolyfillOptions(url, getOptionString); // Kick off the SWF download. - this.load(options, true); + this.load(options, true).then(() => { + const name = this.attributes.getNamedItem("name")?.value; + defineDocumentNamedAccessor(this, name); + }); } } + /** + * @ignore + * @internal + */ + override disconnectedCallback(): void { + const name = this.attributes.getNamedItem("name")?.value; + clearDocumentNamedAccessor(this, name); + super.disconnectedCallback(); + } + protected override debugPlayerInfo(): string { let result = "Player type: Object\n"; @@ -344,4 +360,54 @@ export class RuffleObjectElement extends RufflePlayerElement { set type(typeVal: string) { this.setAttribute("type", typeVal); } + + /** + * @ignore + * @internal + */ + static override get observedAttributes(): string[] { + return [...RufflePlayerElement.observedAttributes, "name"]; + } + + /** + * @ignore + * @internal + */ + override attributeChangedCallback( + name: string, + oldValue: string | undefined, + newValue: string | undefined, + ): void { + super.attributeChangedCallback(name, oldValue, newValue); + if (name === "name" && oldValue !== newValue) { + this.name = newValue ?? ""; + return; + } + } + + /** + * Polyfill of name getter + * + * @ignore + * @internal + */ + get name(): string { + return this._name ?? ""; + } + + /** + * Polyfill of name setter + * + * @ignore + * @internal + */ + set name(name: string) { + const oldName = this._name; + this._name = name; + if (oldName !== name) { + clearDocumentNamedAccessor(this, oldName); + defineDocumentNamedAccessor(this, name); + } + this.setAttribute("name", name); + } } diff --git a/web/packages/selfhosted/test/polyfill/document_accessors/expected.html b/web/packages/selfhosted/test/polyfill/document_accessors/expected.html new file mode 100644 index 000000000000..cdeaf26a9572 --- /dev/null +++ b/web/packages/selfhosted/test/polyfill/document_accessors/expected.html @@ -0,0 +1,18 @@ +
  • document["fl"] returns 4 elements
  • +
  • There are 3 embeds and 1 objects for document["fl"]
  • +
  • document["test"] returns 1 element
  • +
  • There are 1 embeds and 0 objects for document["test"]
  • +
  • document["fl"] returns 3 elements
  • +
  • There are 2 embeds and 1 objects for document["fl"]
  • +
  • document["test"] returns 2 elements
  • +
  • There are 2 embeds and 0 objects for document["test"]
  • +
  • document["fl"] returns 2 elements
  • +
  • There are 1 embeds and 1 objects for document["fl"]
  • +
  • document["test"] returns 1 element
  • +
  • There are 1 embeds and 0 objects for document["test"]
  • +
  • document["fl"] returns 3 elements
  • +
  • There are 2 embeds and 1 objects for document["fl"]
  • +
  • document["test"] returns 0 elements
  • +
  • There are 0 embeds and 0 objects for document["test"]
  • +
  • document["fl"] returns 3 elements
  • +
  • There are 2 embeds and 1 objects for document["fl"]
  • diff --git a/web/packages/selfhosted/test/polyfill/document_accessors/index.html b/web/packages/selfhosted/test/polyfill/document_accessors/index.html new file mode 100644 index 000000000000..d80d776f668c --- /dev/null +++ b/web/packages/selfhosted/test/polyfill/document_accessors/index.html @@ -0,0 +1,17 @@ + + + + + DOCUMENT ACCESSORS + + + +
    + + + + +
    +
      + + diff --git a/web/packages/selfhosted/test/polyfill/document_accessors/test.ts b/web/packages/selfhosted/test/polyfill/document_accessors/test.ts new file mode 100644 index 000000000000..4cb5ae9b77d9 --- /dev/null +++ b/web/packages/selfhosted/test/polyfill/document_accessors/test.ts @@ -0,0 +1,111 @@ +import { injectRuffleAndWait, openTest, playAndMonitor } from "../../utils.js"; +import { use, expect } from "chai"; +import chaiHtml from "chai-html"; +import fs from "fs"; + +use(chaiHtml); + +describe("Document accessor", () => { + it("loads the test", async () => { + await openTest(browser, `polyfill/document_accessors`); + }); + + it("Accesses the right content with ruffle", async () => { + await injectRuffleAndWait(browser); + await playAndMonitor( + browser, + await browser.$("#test-container").$("ruffle-embed#emb1"), + ); + await playAndMonitor( + browser, + await browser.$("#test-container").$("ruffle-embed#emb2"), + ); + await playAndMonitor( + browser, + await browser.$("#test-container").$("ruffle-embed#emb3"), + ); + await playAndMonitor( + browser, + await browser.$("#test-container").$("ruffle-object#obj1"), + ); + await browser.execute(() => { + function countDocumentAccessorElements(name: string) { + const output = document.getElementById("output"); + const els = ( + document as unknown as Record< + string, + HTMLElement | HTMLCollectionOf + > + )[name]; + const len = document.createElement("li"); + const listEach = document.createElement("li"); + let totalEmbeds = 0; + let totalObjects = 0; + + if (!els) { + len.textContent = `document["${name}"] returns 0 elements`; + } + if (els instanceof HTMLElement && els.nodeName === "EMBED") { + len.textContent = `document["${name}"] returns 1 element`; + totalEmbeds++; + } + if (els instanceof HTMLElement && els.nodeName === "OBJECT") { + len.textContent = `document["${name}"] returns 1 element`; + totalObjects++; + } + if (els && "length" in els && els.length) { + len.textContent = `document["${name}"] returns ${els.length} elements`; + for (let i = 0; i < els.length; i++) { + if (els.item(i)!.nodeName === "EMBED") { + totalEmbeds++; + } + if (els.item(i)!.nodeName === "OBJECT") { + totalObjects++; + } + } + } + + listEach.textContent = `There are ${totalEmbeds} embeds and ${totalObjects} objects for document["${name}"]`; + + output?.appendChild(len); + output?.appendChild(listEach); + } + + countDocumentAccessorElements("fl"); + + const emb1 = document.getElementById("emb1") as HTMLEmbedElement; + const emb2 = document.getElementById("emb2") as HTMLEmbedElement; + if (emb1) { + emb1.name = "test"; + } + countDocumentAccessorElements("test"); + countDocumentAccessorElements("fl"); + + if (emb2) { + emb2.setAttribute("name", "test"); + } + countDocumentAccessorElements("test"); + countDocumentAccessorElements("fl"); + + if (emb2) { + emb2.name = "fl"; + } + countDocumentAccessorElements("test"); + countDocumentAccessorElements("fl"); + + if (emb1) { + emb1.remove(); + } + countDocumentAccessorElements("test"); + countDocumentAccessorElements("fl"); + }); + const actual = await browser + .$("#output") + .getHTML({ includeSelectorTag: false, pierceShadowRoot: false }); + const expected = fs.readFileSync( + `${import.meta.dirname}/expected.html`, + "utf8", + ); + expect(actual).html.to.equal(expected); + }); +}); From 6a388ed3c9935a7bb9f5bacfd101251f6bb204a2 Mon Sep 17 00:00:00 2001 From: Daniel Jacobs Date: Wed, 10 Dec 2025 10:21:32 -0500 Subject: [PATCH 2/3] web: For document accessor test, use asserts instead of appending --- .../polyfill/document_accessors/expected.html | 18 -- .../test/polyfill/document_accessors/test.ts | 229 +++++++++++------- 2 files changed, 147 insertions(+), 100 deletions(-) delete mode 100644 web/packages/selfhosted/test/polyfill/document_accessors/expected.html diff --git a/web/packages/selfhosted/test/polyfill/document_accessors/expected.html b/web/packages/selfhosted/test/polyfill/document_accessors/expected.html deleted file mode 100644 index cdeaf26a9572..000000000000 --- a/web/packages/selfhosted/test/polyfill/document_accessors/expected.html +++ /dev/null @@ -1,18 +0,0 @@ -
    • document["fl"] returns 4 elements
    • -
    • There are 3 embeds and 1 objects for document["fl"]
    • -
    • document["test"] returns 1 element
    • -
    • There are 1 embeds and 0 objects for document["test"]
    • -
    • document["fl"] returns 3 elements
    • -
    • There are 2 embeds and 1 objects for document["fl"]
    • -
    • document["test"] returns 2 elements
    • -
    • There are 2 embeds and 0 objects for document["test"]
    • -
    • document["fl"] returns 2 elements
    • -
    • There are 1 embeds and 1 objects for document["fl"]
    • -
    • document["test"] returns 1 element
    • -
    • There are 1 embeds and 0 objects for document["test"]
    • -
    • document["fl"] returns 3 elements
    • -
    • There are 2 embeds and 1 objects for document["fl"]
    • -
    • document["test"] returns 0 elements
    • -
    • There are 0 embeds and 0 objects for document["test"]
    • -
    • document["fl"] returns 3 elements
    • -
    • There are 2 embeds and 1 objects for document["fl"]
    • diff --git a/web/packages/selfhosted/test/polyfill/document_accessors/test.ts b/web/packages/selfhosted/test/polyfill/document_accessors/test.ts index 4cb5ae9b77d9..1ad265245c37 100644 --- a/web/packages/selfhosted/test/polyfill/document_accessors/test.ts +++ b/web/packages/selfhosted/test/polyfill/document_accessors/test.ts @@ -1,9 +1,5 @@ import { injectRuffleAndWait, openTest, playAndMonitor } from "../../utils.js"; -import { use, expect } from "chai"; -import chaiHtml from "chai-html"; -import fs from "fs"; - -use(chaiHtml); +import { expect } from "chai"; describe("Document accessor", () => { it("loads the test", async () => { @@ -11,6 +7,80 @@ describe("Document accessor", () => { }); it("Accesses the right content with ruffle", async () => { + async function setNameAttr(selector: string, value: string) { + const el = await $(selector); + await browser.execute( + (element, val) => { + element.setAttribute("name", val); + }, + el, + value, + ); + } + + async function setName(selector: string, value: string) { + const el = await $(selector); + await browser.execute( + (element, val) => { + (element as HTMLEmbedElement | HTMLObjectElement).name = + val; + }, + el, + value, + ); + } + + async function removeEl(selector: string) { + const el = await $(selector); + await browser.execute((element) => { + element.remove(); + }, el); + } + + async function getAccessorInfo(name: string) { + return await browser.execute((name) => { + const value = ( + document as unknown as Record< + string, + HTMLElement | HTMLCollectionOf + > + )[name]; + const result = { embeds: 0, objects: 0, length: 0, type: "" }; + + if (!value) { + return result; + } + + if (value instanceof HTMLElement) { + result.length = 1; + result.type = value.nodeName; + if (value.nodeName === "EMBED") { + result.embeds = 1; + } + if (value.nodeName === "OBJECT") { + result.objects = 1; + } + return result; + } + + if ("length" in value) { + result.type = "HTMLCollectionLike"; + result.length = value.length; + for (let i = 0; i < value.length; i++) { + const node = value.item(i); + if (node?.nodeName === "EMBED") { + result.embeds++; + } + if (node?.nodeName === "OBJECT") { + result.objects++; + } + } + } + + return result; + }, name); + } + await injectRuffleAndWait(browser); await playAndMonitor( browser, @@ -28,84 +98,79 @@ describe("Document accessor", () => { browser, await browser.$("#test-container").$("ruffle-object#obj1"), ); - await browser.execute(() => { - function countDocumentAccessorElements(name: string) { - const output = document.getElementById("output"); - const els = ( - document as unknown as Record< - string, - HTMLElement | HTMLCollectionOf - > - )[name]; - const len = document.createElement("li"); - const listEach = document.createElement("li"); - let totalEmbeds = 0; - let totalObjects = 0; - if (!els) { - len.textContent = `document["${name}"] returns 0 elements`; - } - if (els instanceof HTMLElement && els.nodeName === "EMBED") { - len.textContent = `document["${name}"] returns 1 element`; - totalEmbeds++; - } - if (els instanceof HTMLElement && els.nodeName === "OBJECT") { - len.textContent = `document["${name}"] returns 1 element`; - totalObjects++; - } - if (els && "length" in els && els.length) { - len.textContent = `document["${name}"] returns ${els.length} elements`; - for (let i = 0; i < els.length; i++) { - if (els.item(i)!.nodeName === "EMBED") { - totalEmbeds++; - } - if (els.item(i)!.nodeName === "OBJECT") { - totalObjects++; - } - } - } + // + // Before changes + // + const fl = await getAccessorInfo("fl"); + expect(fl.type).to.equal("HTMLCollectionLike"); + expect(fl.length).to.equal(4); + expect(fl.embeds).to.equal(3); + expect(fl.objects).to.equal(1); - listEach.textContent = `There are ${totalEmbeds} embeds and ${totalObjects} objects for document["${name}"]`; - - output?.appendChild(len); - output?.appendChild(listEach); - } - - countDocumentAccessorElements("fl"); - - const emb1 = document.getElementById("emb1") as HTMLEmbedElement; - const emb2 = document.getElementById("emb2") as HTMLEmbedElement; - if (emb1) { - emb1.name = "test"; - } - countDocumentAccessorElements("test"); - countDocumentAccessorElements("fl"); - - if (emb2) { - emb2.setAttribute("name", "test"); - } - countDocumentAccessorElements("test"); - countDocumentAccessorElements("fl"); - - if (emb2) { - emb2.name = "fl"; - } - countDocumentAccessorElements("test"); - countDocumentAccessorElements("fl"); - - if (emb1) { - emb1.remove(); - } - countDocumentAccessorElements("test"); - countDocumentAccessorElements("fl"); - }); - const actual = await browser - .$("#output") - .getHTML({ includeSelectorTag: false, pierceShadowRoot: false }); - const expected = fs.readFileSync( - `${import.meta.dirname}/expected.html`, - "utf8", - ); - expect(actual).html.to.equal(expected); + // + // Change emb1.name = "test" + // + await setName("#emb1", "test"); + const test1 = await getAccessorInfo("test"); + const fl1 = await getAccessorInfo("fl"); + + expect(test1.type).to.equal("EMBED"); + expect(test1.length).to.equal(1); + expect(test1.embeds).to.equal(1); + expect(test1.objects).to.equal(0); + + expect(fl1.type).to.equal("HTMLCollectionLike"); + expect(fl1.length).to.equal(3); + expect(fl1.embeds).to.equal(2); + expect(fl1.objects).to.equal(1); + + // + // Change emb2.setAttribute("name", "test") + // + await setNameAttr("#emb2", "test"); + const test2 = await getAccessorInfo("test"); + const fl2 = await getAccessorInfo("fl"); + + expect(test2.type).to.equal("HTMLCollectionLike"); + expect(test2.length).to.equal(2); + expect(test2.embeds).to.equal(2); + expect(test2.objects).to.equal(0); + + expect(fl2.type).to.equal("HTMLCollectionLike"); + expect(fl2.length).to.equal(2); + expect(fl2.embeds).to.equal(1); + expect(fl2.objects).to.equal(1); + + // + // Move emb2 back to name="fl" + // + await setName("#emb2", "fl"); + const test3 = await getAccessorInfo("test"); + const fl3 = await getAccessorInfo("fl"); + + expect(test3.type).to.equal("EMBED"); + expect(test3.length).to.equal(1); + expect(test3.embeds).to.equal(1); + expect(test3.objects).to.equal(0); + + expect(fl3.type).to.equal("HTMLCollectionLike"); + expect(fl3.length).to.equal(3); + expect(fl3.embeds).to.equal(2); + expect(fl3.objects).to.equal(1); + + // + // Remove emb1 + // + await removeEl("#emb1"); + const test4 = await getAccessorInfo("test"); + const fl4 = await getAccessorInfo("fl"); + + expect(test4.type).to.equal(""); + expect(test4.length).to.equal(0); + expect(fl4.type).to.equal("HTMLCollectionLike"); + expect(fl4.length).to.equal(3); + expect(fl4.embeds).to.equal(2); + expect(fl4.objects).to.equal(1); }); }); From f3bd31509bd88ca853479fe4a427238cf70f8a83 Mon Sep 17 00:00:00 2001 From: Daniel Jacobs Date: Wed, 10 Dec 2025 15:14:22 -0500 Subject: [PATCH 3/3] web: Allow indexed access of document accessors --- .../core/src/internal/player/inner.tsx | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/web/packages/core/src/internal/player/inner.tsx b/web/packages/core/src/internal/player/inner.tsx index 747fc0f99d91..d5c842904ab5 100644 --- a/web/packages/core/src/internal/player/inner.tsx +++ b/web/packages/core/src/internal/player/inner.tsx @@ -2580,15 +2580,55 @@ function isCollection( * @returns An HTMLCollectionOf */ function makeCollection(items: HTMLElement[]): HTMLCollectionOf { - return { - length: items.length, - item: (i: number) => items[i] ?? null, - namedItem: (n: string) => - items.find((e) => e.getAttribute("name") === n) ?? null, - [Symbol.iterator]: function* () { - yield* items; + const base = Object.create( + HTMLCollection.prototype, + ) as HTMLCollectionOf; + + Object.defineProperty(base, "length", { + get() { + return items.length; + }, + enumerable: true, + configurable: true, + }); + + base.item = (index: number): HTMLElement | null => { + return items[index] ?? null; + }; + + base.namedItem = (name: string): HTMLElement | null => { + return items.find((el) => el.getAttribute("name") === name) ?? null; + }; + + (base as Iterable)[Symbol.iterator] = + function* (): Iterator { + for (const el of items) { + yield el; + } + }; + + const proxy = new Proxy(base, { + get(target, prop, receiver) { + if (typeof prop === "string") { + const index = Number(prop); + if (!Number.isNaN(index) && index >= 0) { + return items[index]; + } + } + return Reflect.get(target, prop, receiver); }, - } as unknown as HTMLCollectionOf; + has(target, prop) { + if (typeof prop === "string") { + const index = Number(prop); + if (!Number.isNaN(index) && index >= 0) { + return index < items.length; + } + } + return Reflect.has(target, prop); + }, + }); + + return proxy as HTMLCollectionOf; } const docMap = document as unknown as Record; @@ -2609,7 +2649,10 @@ export function clearDocumentNamedAccessor( const existing = docMap[name]; if ( - existing instanceof HTMLCollection || + (existing instanceof HTMLCollection && + Array.from(existing).every((el) => + ["EMBED", "OBJECT"].includes(el.tagName), + )) || (existing instanceof HTMLElement && (existing.tagName === "EMBED" || existing.tagName === "OBJECT")) ) { @@ -2650,7 +2693,10 @@ export function defineDocumentNamedAccessor( const existing = docMap[name]; if ( - existing instanceof HTMLCollection || + (existing instanceof HTMLCollection && + Array.from(existing).every((el) => + ["EMBED", "OBJECT"].includes(el.tagName), + )) || (existing instanceof HTMLElement && (existing.tagName === "EMBED" || existing.tagName === "OBJECT")) ) {