Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions web/packages/core/src/internal/register-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,112 @@ export function lookupElement(elementName: string): Registration | null {
}
}

/**
* Polyfill so document.embeds will return ruffle-embeds too.
*
* @param tries Number of tries before this custom element was defined.
*/
function polyfillDocumentEmbeds(tries: number) {
const orig = Object.getOwnPropertyDescriptor(Document.prototype, "embeds");
if (orig?.get) {
const CACHE_SYM: unique symbol = Symbol("ruffle_embeds_cache");
interface CachedCollection extends HTMLCollection {
[CACHE_SYM]?: true;
}
Object.defineProperty(Document.prototype, "embeds", {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I have one more question: why doesn't it refer back to embeds for unknown keys?

Let's say that a website uses another extension (called foo) that makes some embeds work, and suppose it works similarly to Ruffle, changing embed to foo-embed. foo will also have to polyfill Document.embeds because of the same reasons, but by using the current approach those two polyfills will conflict with each other.

If I were to write this polyfill, I would assume that Document.embeds contains all non-Ruffle embeds, and then refer to it for length and unknown keys, at the same time making sure I don't polyfill it for the second time. Doesn't it make sense?

get(this: Document): CachedCollection {
const documentWithCache = this as unknown as Record<
symbol,
CachedCollection
>;
const existing = documentWithCache[CACHE_SYM];
if (existing) {
return existing;
}

const nodes = (): NodeListOf<Element> => {
const selectors: string[] = ["embed"];

for (let i = 0; i <= tries; i++) {
selectors.push(
i === 0 ? "ruffle-embed" : `ruffle-embed-${i}`,
);
}

return this.querySelectorAll(selectors.join(", "));
};

const base = Object.create(
HTMLCollection.prototype,
) as HTMLCollection;

Object.defineProperty(base, "length", {
enumerable: true,
configurable: true,
get() {
return nodes().length;
},
});

base.item = function (index: number): Element | null {
return nodes()[index] ?? null;
};

base.namedItem = function (name: string): Element | null {
const list = nodes();
for (const el of list) {
const htmlEl = el as HTMLElement;
if (
name &&
(htmlEl.getAttribute("name") === name ||
htmlEl.id === name)
) {
return htmlEl;
}
}
return null;
};

(base as Iterable<Element>)[Symbol.iterator] =
function* (): Iterator<Element> {
for (const el of nodes()) {
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 nodes()[index];
}
}
return Reflect.get(target, prop, receiver);
},
has(target, prop) {
if (typeof prop === "string") {
const index = Number(prop);
if (!Number.isNaN(index) && index >= 0) {
return index < nodes().length;
}
}
return Reflect.has(target, prop);
},
}) as CachedCollection;

proxy[CACHE_SYM] = true;

documentWithCache[CACHE_SYM] = proxy;

return proxy;
},
configurable: true,
enumerable: true,
});
}
}

/**
* Register a custom element.
*
Expand Down Expand Up @@ -86,6 +192,10 @@ export function registerElement(
continue;
} else {
window.customElements.define(externalName, elementClass);

if (elementName === "ruffle-embed") {
polyfillDocumentEmbeds(tries);
}
}

privateRegistry[elementName] = {
Expand Down
15 changes: 15 additions & 0 deletions web/packages/selfhosted/test/polyfill/document_embeds/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>DOCUMENT EMBEDS</title>
</head>

<body>
<div id="test-container">
<embed id="emb1" name="alpha" src="/test_assets/example.swf" width="200" height="200"></embed>
<embed id="emb2" name="beta" src="/test_assets/example.swf" width="200" height="200"></embed>
<embed id="emb3" name="beta" src="/test_assets/example.swf" width="200" height="200"></embed>
</div>
</body>
</html>
136 changes: 136 additions & 0 deletions web/packages/selfhosted/test/polyfill/document_embeds/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { injectRuffleAndWait, openTest } from "../../utils.js";
import { expect } from "chai";

describe("Document embeds", () => {
beforeEach(async () => {
await openTest(browser, `polyfill/document_embeds`);
await injectRuffleAndWait(browser);

await browser
.$("#test-container")
.$("ruffle-embed#emb1")
.waitForExist();
await browser
.$("#test-container")
.$("ruffle-embed#emb2")
.waitForExist();
await browser
.$("#test-container")
.$("ruffle-embed#emb3")
.waitForExist();
});

it("accesses the right number of elements with ruffle", async () => {
async function removeEl(selector: string) {
const el = await $(selector);
await browser.execute((element) => {
element.remove();
}, el);
}

expect(
await browser.execute(() => document.embeds === document.embeds),
).to.equal(true);

expect(
await browser.execute(() => document.embeds?.length ?? 0),
).to.equal(3);

await removeEl("#emb1");

expect(
await browser.execute(() => document.embeds?.length ?? 0),
).to.equal(2);

await browser.execute(() => {
const embed = document.createElement("embed");
embed.src = "about:blank";
embed.type = "text/html";
document.body.appendChild(embed);
});

expect(
await browser.execute(() => document.embeds?.length ?? 0),
).to.equal(3);
});

it("supports index-based access", async () => {
const ids = await browser.execute(() => [
document.embeds.item(0)?.id,
document.embeds[1]?.id,
document.embeds.item(2)?.id,
]);

expect(ids).to.deep.equal(["emb1", "emb2", "emb3"]);
});

it("supports namedItem(name)", async () => {
const result = await browser.execute(() => {
return {
alpha: document.embeds.namedItem("alpha")?.id,
beta: document.embeds.namedItem("beta")?.id,
missing: document.embeds.namedItem("nope"),
};
});

expect(result).to.deep.equal({
alpha: "emb1",
beta: "emb2", // first match
missing: null,
});
});

it("namedItem falls back to id", async () => {
const idMatch = await browser.execute(
() => document.embeds.namedItem("emb3")?.id,
);

expect(idMatch).to.equal("emb3");
});

it("is iterable", async () => {
const ids = await browser.execute(() => {
const result = [];
for (const el of document.embeds) {
result.push(el.id);
}
return result;
});

expect(ids).to.deep.equal(["emb1", "emb2", "emb3"]);
});

it("updates index order after removal", async () => {
await browser.execute(() => {
document.getElementById("emb2")?.remove();
});

const ids = await browser.execute(() =>
Array.from(document.embeds).map((e) => e.id),
);

expect(ids).to.deep.equal(["emb1", "emb3"]);
});

it("includes newly added embeds in order", async () => {
await browser.execute(() => {
const e = document.createElement("embed");
e.id = "emb4";
e.name = "gamma";
e.src = "about:blank";
document.body.appendChild(e);
});

const data = await browser.execute(() => ({
length: document.embeds.length,
lastId: document.embeds.item(3)?.id,
gamma: document.embeds.namedItem("gamma")?.id,
}));

expect(data).to.deep.equal({
length: 4,
lastId: "emb4",
gamma: "emb4",
});
});
});