From 6206cc1ef8675be7f25b89a5708c53ae0a4056d4 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 25 Jul 2026 03:22:57 +0530 Subject: [PATCH] fix(scenes): hold host commands until the ready handshake A freshly created iframe holds an about:blank document that inherits the embedding page's origin until the host document loads. Posting a command at the host origin before then is refused by the browser ("The target origin provided does not match the recipient window's origin") and lands as a console error on every cross-origin embed: ScenesPlayer's mount-time setSceneTheme fires microseconds after the Player mounts the iframe. Gate send() on the kino:ready handshake, which is the earliest point the host is both on its own origin and listening. Nothing is lost, since the pre-ready rate, volume, muted and theme all ride the kino:init reply. The picture-in-picture mirror is about:blank on the pip window's origin under the same rule, so sendMirror() gates on the mirror's own handshake. --- .changeset/scenes-post-before-host-ready.md | 5 ++ src/scenes/provider.test.ts | 64 +++++++++++++++++++++ src/scenes/provider.ts | 23 +++++++- 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 .changeset/scenes-post-before-host-ready.md diff --git a/.changeset/scenes-post-before-host-ready.md b/.changeset/scenes-post-before-host-ready.md new file mode 100644 index 0000000..3577aea --- /dev/null +++ b/.changeset/scenes-post-before-host-ready.md @@ -0,0 +1,5 @@ +--- +"@karnstack/kino": patch +--- + +Scenes: hold every outgoing host command until the `kino:ready` handshake. A freshly created iframe holds an about:blank document that inherits the embedding page's origin until the host document loads, so a command posted at the host origin before then is refused by the browser ("The target origin provided ... does not match the recipient window's origin") and surfaces as a console error. `ScenesPlayer`'s mount-time `setSceneTheme` hit this on every cross-origin embed. Nothing is lost: the pre-ready rate, volume, muted and theme all ride the `kino:init` reply. The picture-in-picture mirror follows the same rule, gated on its own ready handshake. diff --git a/src/scenes/provider.test.ts b/src/scenes/provider.test.ts index 0cfdfb2..ea68f2e 100644 --- a/src/scenes/provider.test.ts +++ b/src/scenes/provider.test.ts @@ -120,11 +120,41 @@ test("init carries the theme option; anything but light falls back to dark", () p.destroy() }) +// Until the host announces kino:ready the frame is still on its initial +// about:blank document, which inherits the embedding page's origin. A command +// targeted at the host origin is refused there ("The target origin provided +// does not match the recipient window's origin"), so nothing may go out early. +test("no command reaches the host frame before the ready handshake", () => { + const p = createScenesProvider({ src: SRC }) + const { iframe } = mount(p) + const posted: unknown[] = [] + iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + p.setSceneTheme("light") + p.actions.play() + p.actions.seek(4) + p.actions.setRate(1.5) + p.actions.setVolume(0.5) + p.actions.setMuted(true) + expect(posted).toEqual([]) + // The dropped pre-ready settings are not lost: they ride the init reply. + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + expect(posted).toContainEqual({ + type: "kino:init", + rate: 1.5, + volume: 0.5, + muted: true, + autoPlay: false, + theme: "light", + }) + p.destroy() +}) + test("setSceneTheme posts kino:setTheme to the master", () => { const p = createScenesProvider({ src: SRC }) const { iframe } = mount(p) const posted: unknown[] = [] iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) p.setSceneTheme("light") expect(posted).toContainEqual({ type: "kino:setTheme", theme: "light" }) p.setSceneTheme("dark") @@ -186,6 +216,7 @@ test("actions post protocol commands to the host", () => { const { iframe } = mount(p) const posted: unknown[] = [] iframe.contentWindow!.postMessage = (msg: unknown) => posted.push(msg) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) p.actions.play() p.actions.seek(21) p.actions.setRate(2) @@ -498,6 +529,36 @@ test("mirror init carries the current theme, not the mount-time one", async () = uninstall() }) +// Same about:blank rule as the master: a mirror created in the pip window is +// on the pip document's origin until it loads, so nothing may be posted at the +// host origin before its own ready handshake. +test("no command reaches the mirror before its ready handshake", async () => { + const fake = new FakePipWindow() + const uninstall = installFakeDocumentPiP(fake) + const p = createScenesProvider({ src: SRC }) + const { iframe } = mount(p) + fromHost(iframe, { type: "kino:ready", duration: 40.5 }) + p.actions.enterPiP() + await vi.waitFor(() => expect(p.getState().pip).toBe(true)) + const mirror = findMirror()! + const mirrorPost = vi.spyOn(mirror.contentWindow!, "postMessage") + p.actions.play() + p.actions.seek(9) + p.setSceneTheme("light") + expect(mirrorPost).not.toHaveBeenCalled() + // The mirror comes up on the master's clock and theme regardless. + fromMirror(fake, mirror, { type: "kino:ready", duration: 40.5 }) + expect(mirrorPost.mock.calls.map((c) => c[0])).toContainEqual( + expect.objectContaining({ + type: "kino:init", + startTime: 9, + theme: "light", + }), + ) + p.destroy() + uninstall() +}) + test("setSceneTheme fans out to the mirror while in pip", async () => { const fake = new FakePipWindow() const uninstall = installFakeDocumentPiP(fake) @@ -507,6 +568,7 @@ test("setSceneTheme fans out to the mirror while in pip", async () => { p.actions.enterPiP() await vi.waitFor(() => expect(p.getState().pip).toBe(true)) const mirror = findMirror()! + fromMirror(fake, mirror, { type: "kino:ready", duration: 40.5 }) const mirrorPost = vi.spyOn(mirror.contentWindow!, "postMessage") const masterPost = vi.spyOn(iframe.contentWindow!, "postMessage") p.setSceneTheme("light") @@ -599,6 +661,7 @@ test("mirror state feeds drift correction only, never MediaState", async () => { p.actions.enterPiP() await vi.waitFor(() => expect(p.getState().pip).toBe(true)) const mirror = findMirror()! + fromMirror(fake, mirror, { type: "kino:ready", duration: 40.5 }) const mirrorPost = vi.spyOn(mirror.contentWindow!, "postMessage") // The mirror reports its own clock; MediaState stays on the master's. fromMirror(fake, mirror, snapshot(1, 5)) @@ -627,6 +690,7 @@ test("transport commands fan out to the mirror while in pip, volume commands nev p.actions.enterPiP() await vi.waitFor(() => expect(p.getState().pip).toBe(true)) const mirror = findMirror()! + fromMirror(fake, mirror, { type: "kino:ready", duration: 40.5 }) const mirrorPost = vi.spyOn(mirror.contentWindow!, "postMessage") p.actions.play() p.actions.pause() diff --git a/src/scenes/provider.ts b/src/scenes/provider.ts index ed8ce44..a0ee851 100644 --- a/src/scenes/provider.ts +++ b/src/scenes/provider.ts @@ -139,12 +139,24 @@ export function createScenesProvider( patch({ fullscreen: false }) } + // A freshly created iframe holds an about:blank document that inherits THIS + // page's origin until the real host document loads, so a command targeted at + // the host origin is refused outright ("The target origin provided does not + // match the recipient window's origin") and lands as a console error. The + // handshake is the earliest point the host is both on its own origin and + // listening, so nothing goes out before it. Nothing is lost: the pre-ready + // rate, volume, muted and theme all ride the kino:init reply. + let ready = false const send = (cmd: HostCommand) => { + if (!ready) return iframe?.contentWindow?.postMessage(cmd, origin) } - // Non-null mirrorIframe implies pip is active; outside pip this is a no-op. + // Same rule for the mirror, which is about:blank on the pip window's origin + // until it loads. False outside pip too, so this is a no-op there. + let mirrorReady = false const sendMirror = (cmd: HostCommand) => { + if (!mirrorReady) return mirrorIframe?.contentWindow?.postMessage(cmd, origin) } @@ -158,6 +170,8 @@ export function createScenesProvider( if (msg == null || typeof msg !== "object") return switch (msg.type) { case "kino:ready": + // Set before the reply: send() itself is gated on this. + ready = true patch({ duration: msg.duration }) send({ type: "kino:init", @@ -209,6 +223,8 @@ export function createScenesProvider( if (msg == null || typeof msg !== "object") return switch (msg.type) { case "kino:ready": { + // Set before the reply: sendMirror() itself is gated on this. + mirrorReady = true // A non-finite currentTime would flow through init startTime straight // into audio.currentTime in the mirror; fall back to the start. const t = state.currentTime @@ -359,6 +375,9 @@ export function createScenesProvider( mirror.style.display = "block" mirrorIframe = mirror mirrorTime = null + // This window's mirror has not announced itself yet, whatever a + // previous pip session left behind. + mirrorReady = false win.document.body.appendChild(mirror) // The mirror host's parent is the pip window, so its events land // there, not on the main window. @@ -395,6 +414,7 @@ export function createScenesProvider( mirrorIframe?.remove() mirrorIframe = null mirrorTime = null + mirrorReady = false pipCleanups.forEach((c) => c()) pipCleanups = [] // Nothing to resume: the master never stopped. @@ -483,6 +503,7 @@ export function createScenesProvider( pipWindow?.close() iframe?.remove() iframe = null + ready = false mountContainer = null listeners.clear() },