From 8841257a91132af18b257420e51778060fdfb78b Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Thu, 24 Sep 2026 21:58:44 -0400 Subject: [PATCH] fix: `AvalComponent` failing to mount on Safari 26 --- packages/element/src/adapter-binding.ts | 19 ++++--- packages/svelte/src/controller.ts | 4 ++ .../svelte/test/browser/LateDefinition.svelte | 12 +++++ .../test/browser/disconnected-host.html | 11 ++++ .../svelte/test/browser/disconnected-host.ts | 27 ++++++++++ .../svelte/test/browser/late-component.html | 12 +++++ .../svelte/test/browser/late-component.ts | 7 +++ .../svelte/test/browser/late-definition.html | 12 +++++ .../test/browser/late-definition.spec.ts | 51 +++++++++++++++++++ .../svelte/test/browser/late-definition.ts | 26 ++++++++++ .../svelte/test/browser/playwright.config.ts | 22 ++++++-- packages/svelte/test/browser/vite.config.ts | 18 ++++++- 12 files changed, 210 insertions(+), 11 deletions(-) create mode 100644 packages/svelte/test/browser/LateDefinition.svelte create mode 100644 packages/svelte/test/browser/disconnected-host.html create mode 100644 packages/svelte/test/browser/disconnected-host.ts create mode 100644 packages/svelte/test/browser/late-component.html create mode 100644 packages/svelte/test/browser/late-component.ts create mode 100644 packages/svelte/test/browser/late-definition.html create mode 100644 packages/svelte/test/browser/late-definition.spec.ts create mode 100644 packages/svelte/test/browser/late-definition.ts diff --git a/packages/element/src/adapter-binding.ts b/packages/element/src/adapter-binding.ts index b30ae37..78ce84b 100644 --- a/packages/element/src/adapter-binding.ts +++ b/packages/element/src/adapter-binding.ts @@ -116,18 +116,23 @@ const BROWSER_ENVIRONMENT: AvalAdapterBindingEnvironment = Object.freeze({ upgrade(node: AvalAdapterBindingNode): AvalAdapterBindingElementPort { defineAvalElement(); const element = node as unknown as AvalAdapterBindingElementPort; - if ( - typeof element.getSnapshot !== "function" || - typeof element.subscribe !== "function" - ) { - throw new TypeError( - "Registered aval-player does not implement the required snapshot API" - ); + if (!hasSnapshotApi(element)) { + customElements.upgrade(node as unknown as Node); + if (!hasSnapshotApi(element)) { + throw new TypeError( + "Registered aval-player does not implement the required snapshot API" + ); + } } return element; } }); +function hasSnapshotApi(element: AvalAdapterBindingElementPort): boolean { + return typeof element.getSnapshot === "function" && + typeof element.subscribe === "function"; +} + export class AvalAdapterBindingImplementation implements AvalAdapterBinding { public readonly commands: Readonly; readonly #statusListeners = new Set(); diff --git a/packages/svelte/src/controller.ts b/packages/svelte/src/controller.ts index e86619e..784a225 100644 --- a/packages/svelte/src/controller.ts +++ b/packages/svelte/src/controller.ts @@ -1,3 +1,4 @@ +import { defineAvalElement } from "@pixel-point/aval-element"; import { createAvalAdapterBinding, createAvalAdapterConfiguration, @@ -22,6 +23,9 @@ export function createAval( if (typeof readOptions !== "function") { throw new TypeError("createAval requires an option getter"); } + // svelte clones hosts from templates, and webkit never upgrades template + // nodes created before the first customElements access + if (typeof globalThis.customElements !== "undefined") defineAvalElement(); const configuration = createAvalAdapterConfiguration(readOptions()); const binding = createAvalAdapterBinding(configuration); diff --git a/packages/svelte/test/browser/LateDefinition.svelte b/packages/svelte/test/browser/LateDefinition.svelte new file mode 100644 index 0000000..a35af0a --- /dev/null +++ b/packages/svelte/test/browser/LateDefinition.svelte @@ -0,0 +1,12 @@ + + + +{$aval.readiness} diff --git a/packages/svelte/test/browser/disconnected-host.html b/packages/svelte/test/browser/disconnected-host.html new file mode 100644 index 0000000..9252c52 --- /dev/null +++ b/packages/svelte/test/browser/disconnected-host.html @@ -0,0 +1,11 @@ + + + + + + Svelte disconnected host regression + + + + + diff --git a/packages/svelte/test/browser/disconnected-host.ts b/packages/svelte/test/browser/disconnected-host.ts new file mode 100644 index 0000000..4dbaac2 --- /dev/null +++ b/packages/svelte/test/browser/disconnected-host.ts @@ -0,0 +1,27 @@ +import { getControllerRecord } from "../../src/controller.js"; +import { createAval } from "../../src/index.js"; + +// touch the registry first so webkit gives template nodes the global one; +// define() still skips this host because it is disconnected +customElements.get("aval-player"); +const template = document.createElement("template"); +template.innerHTML = ""; +const node = document.importNode(template.content, true).firstElementChild; +if (!(node instanceof HTMLElement)) throw new Error("Expected an imported host"); + +const aval = createAval(() => ({ + sources: { h264: "/missing-motion.avl" }, + autoplay: false, + autoBind: false +})); +const result = document.createElement("output"); +result.dataset.testid = "disconnected-attach"; +try { + getControllerRecord(aval).binding.attach(node); + result.textContent = typeof Reflect.get(node, "getSnapshot") === "function" + ? "attached" + : "missing snapshot API"; +} catch (error) { + result.textContent = error instanceof Error ? error.message : String(error); +} +document.body.append(result); diff --git a/packages/svelte/test/browser/late-component.html b/packages/svelte/test/browser/late-component.html new file mode 100644 index 0000000..5457324 --- /dev/null +++ b/packages/svelte/test/browser/late-component.html @@ -0,0 +1,12 @@ + + + + + + Svelte late component definition regression + + +
+ + + diff --git a/packages/svelte/test/browser/late-component.ts b/packages/svelte/test/browser/late-component.ts new file mode 100644 index 0000000..b3a57ac --- /dev/null +++ b/packages/svelte/test/browser/late-component.ts @@ -0,0 +1,7 @@ +import { mount } from "svelte"; + +import LateDefinition from "./LateDefinition.svelte"; + +const target = document.querySelector("#app"); +if (target === null) throw new Error("Svelte browser test mount is missing"); +mount(LateDefinition, { target }); diff --git a/packages/svelte/test/browser/late-definition.html b/packages/svelte/test/browser/late-definition.html new file mode 100644 index 0000000..28c25d8 --- /dev/null +++ b/packages/svelte/test/browser/late-definition.html @@ -0,0 +1,12 @@ + + + + + + Svelte late definition regression + + +
+ + + diff --git a/packages/svelte/test/browser/late-definition.spec.ts b/packages/svelte/test/browser/late-definition.spec.ts new file mode 100644 index 0000000..be30006 --- /dev/null +++ b/packages/svelte/test/browser/late-definition.spec.ts @@ -0,0 +1,51 @@ +import { expect, test, type Locator } from "@playwright/test"; + +async function snapshotApi(player: Locator) { + return player.evaluate((node) => ({ + getSnapshot: typeof Reflect.get(node, "getSnapshot"), + subscribe: typeof Reflect.get(node, "subscribe") + })); +} + +test("attaches a host imported from a template before registration", async ({ + page +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/late-definition.html"); + + await expect(page.getByTestId("imported-attach")).toHaveText("attached"); + expect(await snapshotApi(page.getByTestId("imported-player"))).toEqual({ + getSnapshot: "function", + subscribe: "function" + }); + expect(errors).toEqual([]); +}); + +test("attaches a disconnected host defined after it was imported", async ({ + page +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/disconnected-host.html"); + + await expect(page.getByTestId("disconnected-attach")).toHaveText("attached"); + expect(errors).toEqual([]); +}); + +test("mounts AvalComponent without the page registering aval-player", async ({ + page +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/late-component.html"); + + const player = page.getByTestId("player"); + await expect(player).toHaveAttribute("autoplay", "manual"); + expect(await snapshotApi(player)).toEqual({ + getSnapshot: "function", + subscribe: "function" + }); + await expect(page.getByTestId("controller-readiness")).not.toBeEmpty(); + expect(errors).toEqual([]); +}); diff --git a/packages/svelte/test/browser/late-definition.ts b/packages/svelte/test/browser/late-definition.ts new file mode 100644 index 0000000..1195726 --- /dev/null +++ b/packages/svelte/test/browser/late-definition.ts @@ -0,0 +1,26 @@ +import { getControllerRecord } from "../../src/controller.js"; +import { createAval } from "../../src/index.js"; + +// webkit gives template nodes a null registry until window.customElements has +// been touched, and those nodes never upgrade. nothing here may touch it +// before createAval does. +const aval = createAval(() => ({ + sources: { h264: "/missing-motion.avl" }, + autoplay: false, + autoBind: false +})); +const template = document.createElement("template"); +template.innerHTML = ''; +const node = document.importNode(template.content, true).firstElementChild; +if (!(node instanceof HTMLElement)) throw new Error("Expected an imported host"); +document.body.append(node); + +const result = document.createElement("output"); +result.dataset.testid = "imported-attach"; +try { + getControllerRecord(aval).binding.attach(node); + result.textContent = "attached"; +} catch (error) { + result.textContent = error instanceof Error ? error.message : String(error); +} +document.body.append(result); diff --git a/packages/svelte/test/browser/playwright.config.ts b/packages/svelte/test/browser/playwright.config.ts index bb26f34..ef197af 100644 --- a/packages/svelte/test/browser/playwright.config.ts +++ b/packages/svelte/test/browser/playwright.config.ts @@ -2,27 +2,43 @@ import { defineConfig, devices } from "@playwright/test"; const port = process.env.AVAL_SVELTE_BROWSER_PORT ?? "4188"; const baseURL = `http://127.0.0.1:${port}`; +const previewPort = process.env.AVAL_SVELTE_PREVIEW_PORT ?? "4189"; +const previewURL = `http://127.0.0.1:${previewPort}`; export default defineConfig({ testDir: ".", - testMatch: "optional-attribute-removal.spec.ts", + testMatch: "*.spec.ts", workers: 1, reporter: [["list"]], use: { baseURL, trace: "retain-on-failure" }, - webServer: { + webServer: [{ command: `vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`, url: baseURL, reuseExistingServer: !process.env.CI, timeout: 30_000 - }, + }, { + // the dev client registers its own custom element first, which hides + // webkit's null-registry upgrade failure + command: `vite build --config vite.config.ts --logLevel error && vite preview --config vite.config.ts --host 127.0.0.1 --port ${previewPort} --strictPort`, + url: `${previewURL}/late-definition.html`, + reuseExistingServer: !process.env.CI, + timeout: 60_000 + }], projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"], channel: "chromium" } + }, { + name: "webkit", + testMatch: "late-definition.spec.ts", + use: { + ...devices["Desktop Safari"], + baseURL: previewURL + } }] }); diff --git a/packages/svelte/test/browser/vite.config.ts b/packages/svelte/test/browser/vite.config.ts index ea859cc..eddf238 100644 --- a/packages/svelte/test/browser/vite.config.ts +++ b/packages/svelte/test/browser/vite.config.ts @@ -3,8 +3,24 @@ import { fileURLToPath } from "node:url"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import { defineConfig } from "vite"; +const root = fileURLToPath(new URL(".", import.meta.url)); + export default defineConfig({ - root: fileURLToPath(new URL(".", import.meta.url)), + root, + build: { + outDir: fileURLToPath( + new URL("../../../../.cache/svelte-browser", import.meta.url) + ), + emptyOutDir: true, + rollupOptions: { + input: { + index: `${root}/index.html`, + lateDefinition: `${root}/late-definition.html`, + lateComponent: `${root}/late-component.html`, + disconnectedHost: `${root}/disconnected-host.html` + } + } + }, plugins: [svelte({ configFile: fileURLToPath(new URL("../../svelte.config.js", import.meta.url)) })],